create-hozu 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="https://raw.githubusercontent.com/olevatorr/Hozu/main/docs/assets/logo.png" alt="Hozu logo" width="72">
2
+
1
3
  # create-hozu
2
4
 
3
5
  Create a Hozu app, set up for Claude Code or for agents that read AGENTS.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-hozu",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Create a Hozu app, set up for Claude Code or for agents that read AGENTS.md",
5
5
  "keywords": [
6
6
  "hozu",
package/skill/SKILL.md CHANGED
@@ -5,10 +5,10 @@ description: Build or change an app with the Hozu framework (packages @hozu/*, f
5
5
 
6
6
  # Hozu authoring guide
7
7
 
8
- Hozu is not in your training data. These files are the whole API; do not read `node_modules/@hozu`.
8
+ Hozu is not in your training data. These files are the whole API; skip `node_modules/@hozu`.
9
9
  - **Changing an app:** read `changing.md` first, then only the app's own files.
10
- - **Building an app:** read this file, run `hozu add feature <name> --page /` (a working feature) and edit it;
11
- `patterns.md` says which part of `example/` shows each pattern.
10
+ - **Building an app:** read this file, run `hozu add feature <name> --page / --with auth,detail,toggle,filter,remove`
11
+ (auth = sign-in, per-user data) and edit the texts it lists; don't print the generated files.
12
12
  - **`reference.md`** when the task needs it: routes, DOM fields, no-JS forms, `head`, 404/500, field errors,
13
13
  sessions, languages, env, HTTP, Markdown, images, preview, PWA, page tests, deployment.
14
14
  - **A diagnostic you do not understand:** `diagnostics.md`.
@@ -36,18 +36,18 @@ features/<name>/
36
36
  model.ts schemas, events, query / mutation / tag / fn, the machine
37
37
  views.ts views, contracts, feature()
38
38
  ```
39
- Relative imports end in `.ts`.
40
39
 
41
40
  ## Commands (from the app directory)
42
41
  ```
43
- pnpm exec hozu check # after every edit: types, every rule, every contract
42
+ pnpm exec hozu check # after every edit: types, rules, contracts
44
43
  pnpm exec hozu check --update-lock # accept an intended behaviour change
45
- pnpm exec hozu add feature items --page /items # scaffold a working feature and wire it in
46
- pnpm exec hozu get / /items # try pages without a server: status, title, alerts, text
44
+ pnpm exec hozu add feature items --page / --with detail,toggle # a working feature, wired in
45
+ pnpm exec hozu map # outline of the app with file:line
46
+ pnpm exec hozu get / --select button --forms # pages without a server: text, attributes, forms
47
47
  pnpm exec hozu post / --field title=A --next 'POST / title=a' --next / # submit a form like a browser
48
48
  ```
49
- Each diagnostic has a `file:line`, a cause and a fix: apply the fix, do not work around the rule. Every `get` /
50
- `post` starts from fresh in-memory data, so chain steps with `--next`. Run `node serve.ts` only to use the app.
49
+ Diagnostics give `file:line`, cause and fix: apply the fix. `get` / `post` start from fresh in-memory data;
50
+ chain steps with `--next`. Run `node serve.ts` only to use the app. Relative imports end in `.ts`.
51
51
 
52
52
  ## model.ts
53
53
  ```ts
@@ -128,12 +128,11 @@ export const Board = ui.view({
128
128
  ]),
129
129
  })
130
130
  ```
131
- - `ui.<tag>(attrs, children)`; attribute values are literals, references or guards.
131
+ - `ui.<tag>(attrs, children)`; values are literals, references or guards. `ui.each(list, 'id', (item) => node)`.
132
132
  - `class` is a static string of Tailwind classes that must exist (HZ026). Conditional classes:
133
133
  `toggle: { 'bg-indigo-600 text-white': op.eq(ctx.tab, t) }`. CSS variables: `vars: { '--hue': item.hue }`.
134
134
  - Events: `on: { click: ui.send(Event, payload) }`. Payload fields: literals, references, `ui.dom.value`,
135
135
  `ui.dom.form('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`.
136
- - `ui.each(list, 'id', (item) => node)` (key `null` for primitives).
137
136
  - Links: `ui.link(route, params, search)`, never a string path (HZ032). The third argument exists only when the
138
137
  route declares `search` (`null` = all defaults). Filters that belong in the URL are `search` links, not context.
139
138
  - A form whose submit reads only `ui.dom.form(...)`, literals, context, params and search also works without JS.
package/skill/changing.md CHANGED
@@ -1,28 +1,78 @@
1
1
  # Changing a Hozu app
2
2
 
3
- Keep the loop short: read once, edit everything, check once, verify once.
3
+ Keep the loop short: map once, edit everything, check once, verify once.
4
4
 
5
5
  ## 1. Read
6
6
  - The change request.
7
- - The app: `features/<name>/*.ts`, `server.ts`, `routes.ts`, `hozu.config.ts`. Below, *model* is where the
8
- app keeps schemas, events, effects and the machine (`model.ts` in the recommended layout), and *views* is
9
- where it keeps views, contracts and `feature()`.
10
- - Nothing else. The API is in `SKILL.md`; open `patterns.md` only for a pattern you have not seen in the app,
11
- and `reference.md` only for a topic it lists.
12
-
13
- ## 2. Where each kind of change goes
14
- | Change | Touch, in this order |
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; open `patterns.md` / `reference.md` 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 `op.set(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:**
31
+ - add `priority: 'normal'` to every `Add` payload, to the add effect's input, and to the `done` result;
32
+ - `rejectsInvalid`'s `data.fields` and `changes.fields` get `priority: null`.
33
+ - **server:** store `priority` (seed items included) and return it.
34
+
35
+ ### An action button that works on many items (e.g. "Clear done")
36
+ - **model:**
37
+ - `export const ClearDone = event({ payload: z.object({}) })`;
38
+ - `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()] })`;
39
+ - in `idle`: `on(ClearDone, { target: 'clearing', assign: () => [op.set(ctx.error, null)] })`;
40
+ - a state
41
+ `clearing: { ignore: [...], invoke: invoke(clearDone, { input: {}, done: [{ target: 'idle' }], failed: { Unexpected: [{ target: 'idle', assign: (e) => [op.set(ctx.error, e.message)] }] } }) }`;
42
+ - add `ClearDone` to every busy state's `ignore`, and give `clearing` the same list.
43
+ - **views:** the control
44
+ `ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
45
+ - **Contracts:**
46
+ - `given: { state: 'idle' }`, when `[{ send: ClearDone, payload: {} }, { done: clearDone, result: { removed: 1 } }]`,
47
+ expect `{ state: 'idle', effects: [{ effect: clearDone, input: {} }] }`;
48
+ - a second one for `failed … 'Unexpected'` from `clearing`;
49
+ - add `ClearDone`, `clearDone` and both contracts to `declarations`.
50
+ - **server:**
51
+ `implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
52
+ - **Try it:** `hozu post / --button 'Clear done' --next /`.
53
+
54
+ ### A field shown on the detail page
55
+ In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
56
+ item.
57
+
58
+ ### A detail page, when the feature has none
59
+ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
60
+ - the route with params;
61
+ - the `get` query and its resolver;
62
+ - the detail view;
63
+ - the link in the list;
64
+ - `ui.page(...)` with `head` and `entries`.
65
+
66
+ ### Other changes
67
+ | Change | Touch |
15
68
  |---|---|
16
- | New data field (e.g. `priority`) | model: the domain and input schemas → `server.ts` (seed data, store it) → views: show it, also in the detail view if there is one. If the user picks it in a form: a `<select name="…">` inside the form, sent with the submit as `ui.dom.form('…')` (HZ033 checks the options), plus the matching field in the event payload and the mutation input. |
17
- | New server action (e.g. "clear done") | model: a `mutation` with `invalidates`, an event, and `on(Event)` into a new busy state that `invoke`s the mutation, with `done` and every `failed` handled and the same `ignore` list as the other busy states → views: the control, the contracts, and both new declarations in `feature({ declarations })` → `server.ts`: `implement(...)` it. |
18
- | New UI-only state (a filter, a tab) | model: the context field, its initial value, an event and an `on` that `op.set`s it (add the event to every busy state's `ignore`) → views: the control, a contract, the event in `declarations`. |
19
- | New page | `routes.ts` (`params`, `search`) → a view with `route`, added to `declarations` → `ui.page(...)` in `hozu.config.ts` (with `head`, and `entries` when the route has params). |
20
- | New filter / sort / page number that should be 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. |
69
+ | New UI-only state (a tab) | model: the context field and its initial value, an event, an `on` that `op.set`s it (add the event to every busy state's `ignore`) → views: the control, a contract, the event in `declarations`. |
70
+ | 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. |
71
+ | New page | `routes.ts` → a view with `route`, in `declarations` → `ui.page(...)` in `hozu.config.ts` (`head`, and `entries` when the route has params). |
21
72
 
22
73
  Whenever the machine changes:
23
- - Add one contract per new transition; HZ016 prints each missing one ready to paste. A new context field needs
24
- no change to existing contracts: `given` defaults to the initial context and `changes` lists only what changes.
25
- - Every busy state `ignore`s every event its visible controls can send. HZ005 prints the missing `ignore` entries.
74
+ - Add one contract per new transition; HZ016 prints each missing one ready to paste.
75
+ - Every busy state `ignore`s every event its visible controls can send; HZ005 prints the missing entries.
26
76
 
27
77
  ## 3. Check (once, after all edits)
28
78
  ```
@@ -32,9 +82,13 @@ Fix what it reports. When the behaviour change is intended and everything is cle
32
82
  `pnpm exec hozu check --update-lock`. HZ018 asks for this.
33
83
 
34
84
  ## 4. Verify (once, no server needed)
35
- - Pages: `pnpm exec hozu get / /items/i1` prints status, title, alerts and the visible text.
36
- - Forms: `pnpm exec hozu post / --field title=A --field kind=video --next /items` fills the form like a browser (other
37
- fields keep their defaults), follows the redirect, then requests the next steps in the same process.
38
- - Chain what must share data: `--next 'POST / title=a'` (fields as `a=1&b=2`), `--next /items/i3`.
39
- - A form with only a button (an action such as "Clear done"): `--button 'Clear done'`, or `--next 'POST / @Clear done'`.
40
- One form per item: `--field id=t2` picks the item's form.
85
+ - **Pages:** `pnpm exec hozu get / /items/i1` prints the status, title, alerts and visible text.
86
+ - **Attributes and forms:** `--select button` (or `'[role=alert]'`, `a[href]`, `#id`) prints elements with their
87
+ attributes, e.g. `aria-pressed`; `--forms` lists each form's fields and buttons. Never start a server for this.
88
+ - **Forms:** `pnpm exec hozu post / --field title=A --field priority=high --next /items` fills the form like a
89
+ browser (other fields keep their defaults). It follows the redirect, then runs the next steps in the same
90
+ process.
91
+ - **Chaining:** chain what must share data, e.g. `--next 'POST / title=a'` (fields as `a=1&b=2`) or
92
+ `--next /items/i3`.
93
+ - **A form with only a button:** `--button 'Clear done'`, or `--next 'POST / @Clear done'`. With one form per item,
94
+ `--field id=t2` picks the item's form.
@@ -51,6 +51,10 @@ Every mutation also has the framework error `Invalid` = `{ message, fields }`: o
51
51
  (see `patterns.md`).
52
52
 
53
53
  ## Sessions
54
+ - **Start from the scaffold:** `hozu add feature notes --page / --with auth` writes `features/account`
55
+ (sign-in page, sign-out, `me`), the session cookie in `serve.ts`, per-user resolvers, and a redirect to `/login`
56
+ when signed out. Replace the name-only sign-in with real credentials before production; set `SESSION_SECRET`
57
+ (and `SESSION_SECURE=true` behind HTTPS).
54
58
  - `project({ session: z.object({ user: z.string() }) })` declares the identity. Queries with `scope: 'user'` and
55
59
  mutations receive `session`; public resolvers never do.
56
60
  - `createServer({ session: (request) => value })`, or `sessionCookie({ name, secret })` from
@@ -108,8 +112,12 @@ There are no rewrites: one URL has one owner.
108
112
  dimensions). With `@hozu/image` installed, pass `images: await optimizeImages(build)` to `createServer` (and
109
113
  `hozu build` does it itself): raster assets get WebP `srcset` widths and `sizes`.
110
114
  - **Share images:** `head.render` → `image: ui.og({ title, subtitle })` renders a 1200×630 card; pass
111
- `og: ogImage` (from `@hozu/image`) to `createServer`.
115
+ `og: ogImage` (from `@hozu/image`) to `createServer`. On a static host, use a file instead:
116
+ `image: ui.asset(new URL('./share.png', import.meta.url))` (made absolute with `site.url`).
112
117
  - **Fonts:** a local `@font-face` gets a size-matched `"<Family> Fallback"` automatically.
118
+ - **Page transitions:** the stylesheet turns on cross-document view transitions, so links between pages cross-fade
119
+ instead of flashing (no JS). Turn them off with `@view-transition { navigation: none; }` in `app.css`; style them
120
+ with `::view-transition-*`.
113
121
 
114
122
  ## Preview (drafts)
115
123
  `createServer({ preview: { secret } })`; `GET /_hozu/preview?secret=…&path=/posts/a` turns preview on (a signed
@@ -132,6 +140,9 @@ Cloudflare Workers or Vercel the server is `createHandler({ build, manifest, res
132
140
  `@hozu/runtime-server` with `export default { fetch: handler.fetch }`, where
133
141
  `import * as render from './dist/server/render.js'` is the page code `hozu build` generates (edge runtimes cannot
134
142
  generate it at startup). Page cache and tag revalidation are per instance.
143
+ A fully static site (GitHub Pages, any file host): `exportStatic({ build, styles, resolvers, outDir })` from
144
+ `@hozu/adapter-static` writes every page without per-request data, plus the files they link to, and lists skipped
145
+ routes.
135
146
  ```ts
136
147
  import manifest from './dist/manifest.json' with { type: 'json' }
137
148
  import * as render from './dist/server/render.js'
@@ -9,14 +9,17 @@ A web app built with Hozu (`@hozu/*`). Hozu is not in your training data.
9
9
 
10
10
  ## The loop
11
11
  ```
12
- __RUN__ hozu add feature tasks --page / # start a feature from working code, then edit it
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
13
15
  __RUN__ hozu check # after every change: types, rules, contracts
14
16
  __RUN__ hozu check --update-lock # only to accept a clean, intended behaviour change
15
- __RUN__ hozu get / /tasks/t1 # try pages without a server
17
+ __RUN__ hozu get / --select button --forms # try pages without a server: text, attributes, forms
16
18
  __RUN__ hozu post / --field title=Ship --next / # submit a form like a browser
17
19
  ```
18
20
  __NOTE__
19
21
  ## Rules
22
+ - After `hozu add feature`, do not print the generated files: edit the texts it lists; `hozu map` shows the rest.
20
23
  - Apply the fix each diagnostic gives; do not work around a rule.
21
24
  - Every behaviour change comes with a contract change.
22
25
  - Do not edit `__SKILL__/`: `__RUN__ hozu skill` rewrites it for the installed Hozu version.