create-hozu 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/guide.d.ts +29 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +83 -0
- package/dist/guide.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +39 -92
- package/skill/example/{server.ts → app.ts} +10 -9
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/views.ts +2 -95
- package/skill/example/hozu.config.ts +4 -1
- package/skill/topics/auth.md +10 -6
- package/skill/topics/content.md +1 -1
- package/skill/topics/contracts.md +8 -5
- package/skill/topics/data.md +19 -6
- package/skill/topics/deploy.md +13 -9
- package/skill/topics/diagnostics.md +28 -11
- package/skill/topics/endpoints.md +21 -9
- package/skill/topics/feature.md +79 -0
- package/skill/topics/forms.md +14 -3
- package/skill/topics/http.md +1 -1
- package/skill/topics/i18n.md +11 -6
- package/skill/topics/machine.md +8 -2
- package/skill/topics/pages.md +18 -4
- package/skill/topics/patterns.md +34 -4
- package/skill/topics/recipes.md +48 -0
- package/skill/topics/testing.md +42 -17
- package/skill/topics/views.md +12 -7
- package/skill/topics/widgets.md +6 -4
- package/templates/app/app.ts +7 -0
- package/templates/app/features/site/feature.ts +8 -0
- package/templates/app/features/site/views.ts +1 -7
- package/templates/app/hozu.config.ts +3 -1
- package/templates/guide.md +4 -16
- package/skill/changing.md +0 -91
- package/skill/example/serve.ts +0 -14
- package/templates/app/serve.ts +0 -14
- package/templates/app/server.ts +0 -6
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') }) } }, [
|
|
@@ -33,7 +45,25 @@ ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
|
|
|
33
45
|
// machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
|
|
34
46
|
// toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
|
|
35
47
|
```
|
|
36
|
-
Try it without a server: `hozu
|
|
48
|
+
Try it without a server: `hozu browse / --do 'fill Title=x' --do 'press Enter' --do 'click Done in "x"'`.
|
|
49
|
+
- **Select many, then act** (bulk delete): checkboxes in the list join one form through a formRef; the invoke
|
|
50
|
+
state drops events, so the checkboxes are disabled while it runs:
|
|
51
|
+
```ts
|
|
52
|
+
const bulk = ui.formRef() // module level; context { selected: z.array(z.string()), busy: z.boolean() }
|
|
53
|
+
ui.form({ ref: bulk, on: { submit: ui.send(Bulk, { ids: ui.dom.formAll('ids'), action: ui.dom.form('action') }) } }, [
|
|
54
|
+
ui.button({ type: 'submit', name: 'action', value: 'delete' }, ['Delete selected']),
|
|
55
|
+
ui.button({ type: 'submit', name: 'action', value: 'pin' }, ['Pin selected']),
|
|
56
|
+
])
|
|
57
|
+
ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bulk, name: 'ids', value: item.id,
|
|
58
|
+
'aria-label': `Select ${item.text}`, checked: ctx.selected.includes(item.id), disabled: ctx.busy,
|
|
59
|
+
on: { change: ui.send(Select, { id: item.id, checked: ui.dom.checked }) } }), item.text]))
|
|
60
|
+
// on(Select, { target: 'idle', guard: (e) => e.checked === true, assign: (e) => { ctx.selected.push(e.id) } }),
|
|
61
|
+
// on(Select, { target: 'idle', assign: (e) => { ctx.selected = ctx.selected.filter((id) => id !== e.id) } }),
|
|
62
|
+
// on(Bulk, { target: 'removingMany', guard: (e) => e.action === 'delete', assign: (e) => { ctx.selected = e.ids; ctx.busy = true } }),
|
|
63
|
+
// removingMany: invoke(removeNotes, { input: { ids: ctx.selected }, done/failed: reset selected and busy })
|
|
64
|
+
```
|
|
65
|
+
The mutation input holds the limit (`z.array(z.string()).min(1, 'Select at least one note')`). Reference app:
|
|
66
|
+
`examples/notes`.
|
|
37
67
|
- **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
|
|
38
68
|
- **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
|
|
39
69
|
- **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.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 browse / --do 'click Clear done'` (with and without JS).
|
|
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/testing.md
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
- **
|
|
4
|
-
`
|
|
5
|
-
- `
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
3
|
+
- **Read a page without a server:** `hozu get /path --select 'button[aria-pressed=true]' --forms`.
|
|
4
|
+
- It prints the status, redirect, `set-cookie` attributes (`HttpOnly`, `SameSite`), title, alerts and visible text.
|
|
5
|
+
- `--forms` lists each form: fields with their defaults, checkbox / radio groups with every value (checked ones
|
|
6
|
+
marked ✓), controls that join through `form=` (marked `(form=)`), and submit buttons with their name and value.
|
|
7
|
+
- Endpoints: `hozu get '/api/items?x=1'`. It needs no browser.
|
|
8
|
+
- **Drive the app in a real browser, still without a server:**
|
|
9
|
+
`hozu browse / --session '{"user":"ada"}' --do 'fill New note=Milk' --do 'press Enter' --do 'click Pin in "Milk"'`.
|
|
10
|
+
- It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose); without one it is a config
|
|
11
|
+
error. The app runs in-process, exactly as `npm start` serves it.
|
|
12
|
+
- `--js both` (the default) runs every step with JS and with JS switched off in the same Chrome, side by side. Use
|
|
13
|
+
`--js on` or `--js off` for one mode.
|
|
14
|
+
- Steps: `fill <label>=<value>` (a second fill of a repeated name fills the next field), `select <label>=<option>`,
|
|
15
|
+
`check <label>` / `uncheck <label>` (set the state), `click <name>` (a submit button posts with its name and
|
|
16
|
+
value), `submit "<form>"` (a form's `aria-label` or its submit button text), `press <key>`, `wait <ms>`,
|
|
17
|
+
`goto <path>`. Labels and names are what a user reads (aria-label, `<label>`, placeholder, button text,
|
|
18
|
+
`title`), or a field's `name`.
|
|
19
|
+
- A target may end with `in "<text>"`: the smallest list item, table row or form containing that text
|
|
20
|
+
(`click Delete in "Buy milk"`).
|
|
21
|
+
- To drive both modes with one step list, submit with `press Enter` or `submit "<form>"`. A step with no native
|
|
22
|
+
effect prints `js-only (<reason>)` in the off column, e.g. a `type=button` button.
|
|
23
|
+
- **Other users, other pages, after a reload, after sign-out:** verify any such statement once, in one `browse`
|
|
24
|
+
chain with `--js both`.
|
|
25
|
+
- `--as <name>` starts an actor with its own browser; the steps after it are that actor's, and a later
|
|
26
|
+
`--as <name>` switches back. `--session` right after an `--as` signs that actor in. All actors share one app
|
|
27
|
+
(one data store, one session store), so what ada writes is what bob reads.
|
|
28
|
+
- Signing out and in again inside one actor's chain also works (`click Sign out`, `fill Name=bob`, `press Enter`).
|
|
29
|
+
- `hozu browse /notes --as ada --session '{"user":"ada"}' --as bob --session '{"user":"bob"}' --as ada --do 'click Share in "Milk"' --as bob --do 'goto /inbox'`
|
|
30
|
+
- **The output** is small on purpose: per step, only the lines it added (`+`) or removed (`−`), once when both modes
|
|
31
|
+
agree and per mode where they differ; a navigation prints `→ <path>` and the new page's lines; a live update on
|
|
32
|
+
another actor's page prints under the step (`bob: + Milk`). A passing six-step run stays under 1.5 KB.
|
|
33
|
+
- `≠ DIFFERS` marks a step where both modes made a request and the resulting text differs: a no-JS/JS parity bug.
|
|
34
|
+
- Errors: uncaught exceptions, `console.error`s, CSP violations and failed requests, each with the page, the
|
|
35
|
+
resource type and the mode. A 400 re-render of an invalid native post is not an error.
|
|
36
|
+
- Exit code 1 when a step failed, the modes differ, a widget failed or any error was printed. `--json` has every
|
|
37
|
+
line; `--full` prints them all; `--select <css>`, `--screenshot shot.png` and `--reduced-motion` as before.
|
|
38
|
+
- It also prints the widgets on the page (mounted, failed, size, canvases).
|
|
39
|
+
- In code: `const page = await testApp(app).get('/')` from `@hozu/testing`, with `app` the default export of
|
|
40
|
+
`app.ts` → `{ status, headers, html, text, payload }`; `.post(path, fields)` submits a native form, with fields as
|
|
41
|
+
a record or as `[name, value]` pairs for repeated names. `testApp(app, { session: store })` may swap only the
|
|
42
|
+
session store (a test issuer).
|
|
43
|
+
- `hozu get`, `hozu browse` and `testApp` build the app module; a build with errors exits 1 (throws) and renders
|
|
44
|
+
nothing: run `hozu check`.
|
|
20
45
|
- Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
|
|
21
46
|
- Browser tests: wait for `html[data-hozu-ready]` (set after hydration) before clicking.
|
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
|
```
|
|
@@ -14,20 +15,24 @@ export const Board = ui.view({
|
|
|
14
15
|
- **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026). Conditional classes:
|
|
15
16
|
`toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. CSS variables: `vars: { '--hue': item.hue }`. No `style`.
|
|
16
17
|
- **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`,
|
|
17
|
-
`list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])
|
|
18
|
-
`ui.if(cond, [then], [else], 'fade')
|
|
18
|
+
`list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`; a branch may be a list: `open ? [a, b] : null`, also as what a query branch or an each item returns.
|
|
19
|
+
With an enter/leave animation: `ui.if(cond, [then], [else], 'fade')` (the motion name is required).
|
|
19
20
|
- **By machine state:** `when(['adding', 'saving'], [ui.p({}, ['Saving…'])])`.
|
|
20
21
|
- **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`; `ui.each(tags, null, (t) => …)` for primitives.
|
|
21
22
|
Never `.map` over data (only over constants: `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`).
|
|
22
23
|
- **Text:** template strings work: `` `${n} items` ``.
|
|
24
|
+
- **Reuse:** `export const row = part((item: Item) => ui.li({}, [item.done ? 'Done' : item.title]))`, called as
|
|
25
|
+
`row(item)`; it is inlined, so the IR equals the inline form. A plain function that receives data is HZ059.
|
|
23
26
|
- **Events:** `on: { click: ui.send(Event, payload) }`, any DOM event name plus `visible` (entered the viewport).
|
|
24
|
-
Payload fields: literals, data, `ui.dom.value`, `ui.dom.form('name')
|
|
25
|
-
`ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field
|
|
26
|
-
|
|
27
|
+
Payload fields: literals, data, `ui.dom.value`, `ui.dom.form('name')` / `ui.dom.formAll('name')` (submit; `hozu docs
|
|
28
|
+
forms`), `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field
|
|
29
|
+
only from a `<select>`, radios or submit buttons whose literal values are all members (HZ033).
|
|
27
30
|
- **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). The third
|
|
28
|
-
argument exists only when the route declares `search`:
|
|
31
|
+
argument is optional and exists only when the route declares `search`: omitted means every default, and a search
|
|
32
|
+
lists only the fields that differ: `ui.link(home, null, { show: 'done' })`.
|
|
29
33
|
- **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
|
|
34
|
+
() => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`; a branch may return `null` to render
|
|
35
|
+
nothing.
|
|
31
36
|
Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
|
|
32
37
|
invalidates refresh in place.
|
|
33
38
|
- **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
|
package/skill/topics/widgets.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Widgets (browser APIs, DOM libraries)
|
|
2
2
|
|
|
3
|
-
Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the `
|
|
3
|
+
Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the bundle in `app.ts`
|
|
4
4
|
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,9 +19,11 @@ 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
|
-
- `
|
|
24
|
-
them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
|
|
25
|
+
- `app.ts` passes `widgets: bundleWidgets` to `app()` (`import { bundleWidgets } from '@hozu/bundle'`; without it
|
|
26
|
+
the server refuses to start and `hozu check` reports HZ045; `hozu build` bundles them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
|
|
25
27
|
height class.
|
|
26
28
|
- Check it with `hozu browse /` (no server): each widget is listed as mounted / failed / not mounted with its size
|
|
27
29
|
and canvases, next to any error it threw. A mounted host carries `data-hozu-widget="<feature>.<Name>"` and
|
|
@@ -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,11 +1,13 @@
|
|
|
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({
|
|
7
8
|
schema: zodAdapter,
|
|
8
9
|
styles: new URL('./app.css', import.meta.url),
|
|
10
|
+
app: new URL('./app.ts', import.meta.url),
|
|
9
11
|
site: { url: 'http://localhost:3000', name: '__NAME__', lang: 'en' },
|
|
10
12
|
routes: { home },
|
|
11
13
|
pages: [ui.page(home, { views: [Home], head: { render: () => ({ title: '__NAME__' }) } })],
|
package/templates/guide.md
CHANGED
|
@@ -3,24 +3,12 @@
|
|
|
3
3
|
A web app built with Hozu (`@hozu/*`). Hozu is not in your training data.
|
|
4
4
|
|
|
5
5
|
## Before writing code
|
|
6
|
-
- __READ__
|
|
7
|
-
- Changing existing code: read `__SKILL__/changing.md` first.
|
|
6
|
+
- __READ__ It has the change loop, the commands and the rules no diagnostic checks.
|
|
8
7
|
- The files in `__SKILL__/` are the whole API. Do not read the framework source in `node_modules/@hozu`.
|
|
9
|
-
|
|
10
|
-
## The loop
|
|
11
|
-
```
|
|
12
|
-
__RUN__ hozu add feature tasks --page / --with detail,toggle,filter,remove # then edit the texts it lists
|
|
13
|
-
# add auth to the list for sign-in and per-user data
|
|
14
|
-
__RUN__ hozu map # outline of the app with file:line, before a change
|
|
15
|
-
__RUN__ hozu check # after every change: types, rules, contracts
|
|
16
|
-
__RUN__ hozu check --update-lock # only to accept a clean, intended behaviour change
|
|
17
|
-
__RUN__ hozu get / --select button --forms # try pages without a server: text, attributes, forms
|
|
18
|
-
__RUN__ hozu post / --field title=Ship --next / # submit a form like a browser
|
|
19
|
-
__RUN__ hozu browse / --do 'click Save' # real browser, no server: errors, widgets, text after steps
|
|
20
|
-
```
|
|
21
8
|
__NOTE__
|
|
22
9
|
## Rules
|
|
23
10
|
- After `hozu add feature`, do not print the generated files: edit the texts it lists; `hozu map` shows the rest.
|
|
24
11
|
- Apply the fix each diagnostic gives; do not work around a rule.
|
|
25
|
-
-
|
|
26
|
-
- Do not edit `__SKILL__
|
|
12
|
+
- `__RUN__ hozu get` and `__RUN__ hozu browse` run the same app as `npm start`; do not start a server to check.
|
|
13
|
+
- Do not edit `__SKILL__/` or the text between the `hozu` markers: `__RUN__ hozu skill` rewrites both for the
|
|
14
|
+
installed Hozu version.
|
package/skill/changing.md
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
# Changing a Hozu app
|
|
2
|
-
|
|
3
|
-
Keep the loop short: map once, edit everything, check once, verify once.
|
|
4
|
-
|
|
5
|
-
## 1. Read
|
|
6
|
-
- The change request.
|
|
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. Below, *model* is where the app keeps schemas, events, effects and
|
|
9
|
-
the machine (`model.ts`), and *views* where it keeps views, contracts and `feature()`.
|
|
10
|
-
- The API is in `SKILL.md`. Use a recipe below when one fits; run `hozu docs <topic>` only for
|
|
11
|
-
something else.
|
|
12
|
-
|
|
13
|
-
## 2. Recipes
|
|
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
|
|
62
|
-
| Change | Touch |
|
|
63
|
-
|---|---|
|
|
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, the event in `declarations` (no contract: it only copies a value). |
|
|
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. No machine change. |
|
|
66
|
-
| New page | `routes.ts` → a view with `route`, in `declarations` → `ui.page(...)` in `hozu.config.ts` (`head`, and `entries` when the route has params). |
|
|
67
|
-
|
|
68
|
-
Whenever the machine changes:
|
|
69
|
-
- A transition that decides something (a guard, `navigate`, or a `fn` in its values) needs a contract; HZ016
|
|
70
|
-
prints each missing one ready to paste. Transitions that only copy values are reviewed through the lock.
|
|
71
|
-
- States with `invoke` drop unhandled events by themselves. Other states must handle or `ignore` every event their
|
|
72
|
-
visible controls send; HZ005 prints the missing entries.
|
|
73
|
-
|
|
74
|
-
## 3. Check (once, after all edits)
|
|
75
|
-
```
|
|
76
|
-
pnpm exec hozu check
|
|
77
|
-
```
|
|
78
|
-
Fix what it reports. When the behaviour change is intended, run `pnpm exec hozu check --update-lock`: HZ018 shows
|
|
79
|
-
each changed transition as `was: … now: …`, and accepting it updates the lock.
|
|
80
|
-
|
|
81
|
-
## 4. Verify (once, no server needed)
|
|
82
|
-
- **Pages:** `pnpm exec hozu get / /items/i1` prints the status, title, alerts and visible text.
|
|
83
|
-
- **Attributes and forms:** `--select button` (or `'[role=alert]'`, `a[href]`, `#id`) prints elements with their
|
|
84
|
-
attributes, e.g. `aria-pressed`; `--forms` lists each form's fields and buttons. Never start a server for this.
|
|
85
|
-
- **Forms:** `pnpm exec hozu post / --field title=A --field priority=high --next /items` fills the form like a
|
|
86
|
-
browser (other fields keep their defaults). It follows the redirect, then runs the next steps in the same
|
|
87
|
-
process.
|
|
88
|
-
- **Chaining:** chain what must share data, e.g. `--next 'POST / title=a'` (fields as `a=1&b=2`) or
|
|
89
|
-
`--next /items/i3`.
|
|
90
|
-
- **A form with only a button:** `--button 'Clear done'`, or `--next 'POST / @Clear done'`. With one form per item,
|
|
91
|
-
`--field id=t2` picks the item's form.
|
package/skill/example/serve.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { createServer } from '@hozu/adapter-node'
|
|
2
|
-
import { buildProject } from '@hozu/core/ir'
|
|
3
|
-
import { compileStyles } from '@hozu/css'
|
|
4
|
-
import project from './hozu.config.ts'
|
|
5
|
-
import { createResolvers } from './server.ts'
|
|
6
|
-
|
|
7
|
-
const port = Number(process.env.PORT ?? 3000)
|
|
8
|
-
const build = buildProject(project, { sources: false })
|
|
9
|
-
|
|
10
|
-
createServer({
|
|
11
|
-
build,
|
|
12
|
-
styles: await compileStyles(build),
|
|
13
|
-
resolvers: createResolvers(),
|
|
14
|
-
}).listen(port, () => console.log(`Bookmarks on http://localhost:${port}`))
|
package/templates/app/serve.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { createServer } from '@hozu/adapter-node'
|
|
2
|
-
import { buildProject } from '@hozu/core/ir'
|
|
3
|
-
import { compileStyles } from '@hozu/css'
|
|
4
|
-
import project from './hozu.config.ts'
|
|
5
|
-
import { createResolvers } from './server.ts'
|
|
6
|
-
|
|
7
|
-
const port = Number(process.env.PORT ?? 3000)
|
|
8
|
-
const build = buildProject(project, { sources: false })
|
|
9
|
-
|
|
10
|
-
createServer({
|
|
11
|
-
build,
|
|
12
|
-
styles: await compileStyles(build),
|
|
13
|
-
resolvers: createResolvers(),
|
|
14
|
-
}).listen(port, () => console.log(`__NAME__ on http://localhost:${port}`))
|