create-hozu 0.7.0 → 0.9.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.
@@ -0,0 +1,67 @@
1
+ # Components (shared UI, kits, browser code)
2
+
3
+ A UI piece used by several features is a component in a kit. Start with `hozu add kit ui` (writes `ui/kit.ts`,
4
+ `ui/tv.ts` and `project({ kits })`), then `hozu add component ui Button`; `hozu add component <feature> <Name>` makes
5
+ one private to that feature.
6
+ ```ts
7
+ // ui/button.ts; ui/kit.ts lists it: ui.kit({ id: 'ui', components: [button, input] })
8
+ const styles = tv({ // tv from ./tv.ts
9
+ base: 'inline-flex gap-2 rounded px-4 py-2 disabled:opacity-50 aria-busy:cursor-wait',
10
+ variants: { tone: { primary: 'bg-indigo-600 text-white', ghost: 'text-slate-700 hover:bg-slate-100' } },
11
+ defaultVariants: { tone: 'primary' },
12
+ })
13
+ export const Button = ui.component({
14
+ tag: 'button', styles, slots: ['icon'], children: true, events: ['press'],
15
+ props: z.object({ type: z.enum(['button', 'submit']).default('button'), busy: z.boolean().default(false) }),
16
+ render: ({ props, slots, children, on }) =>
17
+ ui.button({ type: props.type, 'aria-busy': props.busy, on: { click: on.press } }, [slots.icon, ...children]),
18
+ })
19
+ ```
20
+ ```ts
21
+ ui.use(Button, { variant: { tone: 'ghost' }, props: { busy: ctx.saving }, slots: { icon: ui.span({}, ['+']) },
22
+ on: { press: ui.send(Save, {}) }, class: 'w-full' }, ['Save']) // in a view; its id is ui.Button
23
+ ```
24
+ - **`ui.use` keys:** `variant` (literals), `props` (data), `slots`, `on`, `class`; children only with
25
+ `children: true`. Every key is optional when the component needs none.
26
+ - **Variants vs props:** a variant is a fixed look chosen in the view (HZ071 for data); anything that changes while
27
+ the page runs is a prop. Style a state through the attribute that announces it: `disabled:`, `aria-pressed:`,
28
+ `aria-busy:`, `aria-invalid:`, `aria-expanded:`, `open:`. `toggle` is for states without one.
29
+ - **Render:** it reads only `props`, `slots`, `children`, `on` and `classes` (the other tv slots,
30
+ `classes.label`); the caller passes sends, links and text in (HZ070). Hozu puts the root class on the root.
31
+ - **Extension:** `class` may add classes that set none of the component's properties (`w-full`, `relative`,
32
+ `md:hidden`). To change one, declare a variant; a one-off ends with `!` (`rounded-lg!`) (HZ072–HZ077).
33
+ `hozu check` counts the `!` per component; `extend: false` refuses every class.
34
+ - A view fragment that two features inline is a component, not a `part()` (HZ080).
35
+
36
+ ## Client components (browser APIs, DOM libraries)
37
+ `hozu add component <kit|feature> <Name> --client` writes the declaration, the client module, the bundle in `app.ts`
38
+ and the `@hozu/bundle` dependency.
39
+ ```ts
40
+ export const Map = ui.component({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
41
+ emits: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
42
+ load: 'visible', render: () => ui.div({}, []) }) // 'eager' | 'visible' | 'idle'
43
+ ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
44
+ class: 'h-96 w-full' })
45
+ ```
46
+ ```ts
47
+ // map.client.ts: a type-only import of the declaration
48
+ import { implement } from '@hozu/core/component'
49
+ import type { Map } from './components.ts'
50
+ export default implement<typeof Map>(({ el, props, emit, signal }) => {
51
+ const map = createMap(el, props) // any DOM library
52
+ map.on('pick', (id) => emit('picked', { id }))
53
+ return { update(next) { map.move(next) }, destroy() { map.remove() } }
54
+ })
55
+ ```
56
+ - The render is the server HTML the module takes over; with `children: true` the children stay as the no-JS
57
+ fallback. The root takes no attributes or `on`: put a role or label on a wrapping element.
58
+ - `app.ts` passes `components: bundleComponents` (`@hozu/bundle`) to `app()` (HZ045 without it). A library's CSS
59
+ goes in `app.css`; a map or chart host needs a height class.
60
+ - `hozu browse /` lists each one as mounted / failed / not mounted with its size and canvases; a mounted host has
61
+ `data-hozu-component="<id>"` and `data-hozu-component-state="mounted"`.
62
+
63
+ ## Look them up
64
+ - `hozu docs components` (this topic, then the app's list), `hozu inspect ui.Button` (variants, props, owned
65
+ classes, every use), `hozu impact ui.Button`.
66
+ - `hozu render ui.Button --variant tone=ghost --props '{"busy":true}' --slot icon=+` renders it alone: HTML, root
67
+ class, owned properties, diagnostics.
@@ -1,7 +1,7 @@
1
1
  # Markdown, images, share images, fonts, preview, offline
2
2
 
3
3
  - **Markdown:** `@hozu/content`: `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`
4
- in `server.ts` gives `{ slug, data, html, headings }`; return it from query resolvers and render `ui.html(post.html)`.
4
+ in `app.ts` gives `{ slug, data, html, headings }`; return it from query resolvers and render `ui.html(post.html)`.
5
5
  - **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
6
6
  dimensions). With `@hozu/image`, `hozu build` adds WebP `srcset` widths.
7
7
  - **Share images:** `head.render → image: ui.og({ title, subtitle })` (needs `og: ogImage` from `@hozu/image` in
@@ -1,8 +1,11 @@
1
1
  # Contracts
2
2
 
3
- A transition that **decides** needs a contract: a guard, a `navigate`, or a `fn()` in its values (HZ016 prints each
4
- missing one, ready to paste). Transitions that only copy values need none: `hozu.lock.json` records every transition
5
- in readable form, and a change shows as HZ018 `was: … now: …` until `hozu check --update-lock` accepts it.
3
+ A transition that **decides** needs a contract: a guard, a `navigate`, or a `fn()`, a comparison or a computing
4
+ operator (`+ - ?? ?: .length .includes`) in its values (HZ016 prints each missing one, ready to paste). Transitions that
5
+ only copy values need none (a contract there is HZ058). `hozu.lock.json` records every transition readably and must
6
+ equal the computed lock: any difference is HZ057 until `hozu check --update-lock` accepts it; then list the accepted
7
+ `now:` lines in your summary. A deciding change also needs a contract that fails against the old behaviour (HZ018);
8
+ renaming or copying a contract does not count.
6
9
  ```ts
7
10
  export const addsValid = contract(m, {
8
11
  given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
@@ -4,8 +4,8 @@
4
4
  export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
5
5
  export const listItems = query({
6
6
  input: z.object({}), output: z.array(Item),
7
- scope: 'public', // 'user' = per-session data (needs project({ session }))
8
- freshness: 'static', // | { revalidate: seconds } | { swr: seconds } | 'live'
7
+ scope: 'public', // 'user' = the session's data (needs project({ session }))
8
+ freshness: 'static', // | 'request' | { revalidate: seconds } | { swr: seconds } | 'live'
9
9
  tags: () => [itemsTag()], // optional; (input) => [...]
10
10
  })
11
11
  export const getItem = query({ input: Key, output: Item, errors: { NotFound: Key }, scope: 'public',
@@ -25,13 +25,24 @@ export const visible = fn({ // computation: pure JS; may call
25
25
  it. Imported names and `let` state are not (HZ047): pass them as input.
26
26
  - Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
27
27
  `scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
28
- - **Resolvers** (`server.ts`, or `features/<name>/server.ts` from the scaffold):
28
+ - Freshness: public data is `'static'` with tags unless it changes without a declared writer. `'request'` reads
29
+ once per request (any scope; a public one makes its page uncacheable). User data is `'request'` or `'live'` only
30
+ (HZ049). `'live'` is only for push updates and needs tags (HZ050).
31
+ - `invalidates` drives the refresh: after a mutation or endpoint, cached pages and entries with those tags are
32
+ dropped and the page's queries with those tags are re-read. `endpoint({ …, invalidates: (input) => [tag()] })`
33
+ applies when it succeeds (use POST; a GET write is HZ062).
34
+ - Writes from outside (a webhook, a job): `await server.revalidate([itemsTag()])` → `{ entries, pages }`.
35
+ - **Query resolvers only read.** Writes happen in mutation and endpoint resolvers: a query that creates a row on read
36
+ runs again on every request, on prefetch and after a delete (the account comes back). Keep two helpers:
37
+ `listOf(user)` returns the stored list or `[]` for queries; `ownListOf(user)` creates it, for mutations only.
38
+ - **Resolvers** (in `app.ts`, or `features/<name>/server.ts` from the scaffold, spread into it) get the
39
+ schema-parsed input (defaults and transforms applied):
29
40
  ```ts
30
- export const createResolvers = () => resolvers(project, (implement) => [
41
+ export default app({ resolvers: resolvers(project, (implement) => [
31
42
  implement(listItems, () => items.map((i) => ({ ...i }))),
32
43
  implement(getItem, ({ id }, { fail }) => items.find((i) => i.id === id) ?? fail('NotFound', { id })),
33
44
  implement(addItem, ({ title }, { fail, session }) => /* … */ ),
34
- ])
45
+ ]) })
35
46
  ```
36
47
  - Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
37
48
  `fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
@@ -1,12 +1,16 @@
1
1
  # Deployment
2
2
 
3
- - **Node:** `npm start` (`node --import @hozu/transform/register serve.ts`). `hozu build` writes `dist/public/`,
4
- `dist/manifest.json` and `dist/server/render.js`; serve with
5
- `createServer({ build: buildProject(project, { manifest }), manifest, publicDir: 'dist/public', … })`.
3
+ - **One app module:** `project({ app: new URL('./app.ts', import.meta.url) })`, and `app.ts` default-exports
4
+ `app({ resolvers: resolvers(project, (implement) => [...]), session?, components?, og?, csp?, onError?, preview? })`
5
+ from `@hozu/runtime-server`. `hozu serve`, `hozu check`, `hozu get`, `hozu browse` and `testApp(app)` all run this
6
+ module, so what the tools verify is what production serves. There is no wrapper position: headers go through
7
+ `project({ http })`, statuses through `head.failed` and endpoint `failed`, the language through the URL.
8
+ - **Node:** `npm start` is `hozu serve`: adapter-node on `PORT` (and `HOST`), `process.env`, styles, images and
9
+ `public/`. `hozu build` writes `dist/public/`, `dist/manifest.json` and `dist/server/render.js`.
6
10
  - **Edge (Bun, Deno, Workers, Vercel):** bundle with `hozuTransform()` from `@hozu/transform/esbuild`, then
7
- `createHandler({ build: buildProject(project, { manifest }), manifest, render, resolvers })` from
8
- `@hozu/runtime-server`, `export default { fetch: handler.fetch }`, where `render` is `import * as render from
9
- './dist/server/render.js'`.
10
- - **Static host (GitHub Pages):** `exportStatic({ build, styles, resolvers, outDir })` from `@hozu/adapter-static`
11
- writes every page without per-request data plus the files they link to, and lists skipped routes.
12
- - Set `SESSION_SECRET` (and `SESSION_SECURE=true`) when the app has sessions.
11
+ `createHandler(app, { manifest, render, env })` from `@hozu/runtime-server` and
12
+ `export default { fetch: handler.fetch }`, where `render` is `import * as render from './dist/server/render.js'`.
13
+ - **Static host (GitHub Pages):** `exportStatic({ build, styles, resolvers: appOptionsOf(app).resolvers, outDir })`
14
+ from `@hozu/adapter-static` writes every page without per-request data, and lists skipped routes.
15
+ - Set `SESSION_SECRET` when the app has sessions (production refuses to start without it). The default store keeps
16
+ sessions in memory per process; an edge or multi-instance deployment passes a shared store as `app({ session })`.
@@ -15,21 +15,22 @@ around the rule.
15
15
  | HZ009 | a guardless transition shadows later ones | put guarded transitions first |
16
16
  | HZ014 | wrong builder output, or a method called on data (`.map`, `.toUpperCase()`) | follow the builder signature; lists: `ui.each`; computation: a `fn()` |
17
17
  | HZ015 / HZ017 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
18
- | HZ016 | a transition that decides (guard, `navigate`, `fn`) has no contract | add the contract from the snippet |
19
- | HZ018 | behaviour changed; the message shows `was: … now: …` | decision: update its contract; copy-only transition: `--update-lock` if intended |
20
- | HZ021 | a query or mutation without a resolver | `implement(...)` it in server.ts |
18
+ | HZ016 | a transition that decides (guard, `navigate`, a `fn`, comparison or `+ - ?? ?: .length .includes` in a value) has no contract | add the contract from the snippet |
19
+ | HZ018 | a deciding transition changed (fields first, then `was:` / `now:`) and no covering contract fails against the old behaviour | change or add a contract that specifies the new behaviour; renaming or copying one does not count |
20
+ | HZ021 | a query, mutation or endpoint without a resolver | `implement(...)` it in the resolvers of `app.ts` |
21
21
  | HZ022 | user data in a cacheable region | keep `scope: 'user'` queries out of cached pages |
22
- | HZ024 / HZ025 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` | align them / add `entries` |
22
+ | HZ024 / HZ025 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
23
23
  | HZ026 | a class produces no CSS | fix the Tailwind class |
24
24
  | HZ027 | a DOM field used outside an event, or wrong for this event | read `ui.dom.*` only in `ui.send` payloads |
25
25
  | HZ028 | `img` without width/height | add both |
26
+ | HZ029 | a client component's module is missing, or a handler for an event it does not emit | create the module (`hozu add component … --client`); handle declared `emits` only |
26
27
  | HZ030 | `ui.html` of untrusted data | render text instead |
27
28
  | HZ031 | a literal not allowed by its schema | use an allowed value (the patch suggests one) |
28
- | HZ032 | internal link written as a string | `ui.link(route, params)` |
29
- | HZ033 | DOM text into an enum, number or boolean field | a `<select>` with enum options / `valueAsNumber` / `checked` |
29
+ | HZ032 | internal link or form action written as a string | `ui.link(route, params)` / `ui.link(endpoint)` |
30
+ | HZ033 | DOM text into an enum, number or boolean field | a `<select>`, radios or submit buttons with enum values; in a form, a flag through `ui.dom.formAll` and numbers parsed in the mutation input |
30
31
  | HZ034 | a state both handles and ignores an event | remove it from one of the two |
31
32
  | HZ035 | search schema is not a flat object of scalars with defaults | `z.object({ key: scalar.default(…) })` |
32
- | HZ036 | (warning) a form needs JavaScript | read its values with `ui.dom.form('name')` |
33
+ | HZ036 | (warning) a form needs JavaScript | read its values with `ui.dom.form('name')` / `ui.dom.formAll('name')` |
33
34
  | HZ037 | a redirect is not a path, hides a page or another redirect, or targets an unknown route | change or remove the `from` key; point `to` at `ui.link(...)` |
34
35
  | HZ038 | `http.headers` sets a header the framework owns, or an invalid name/value | remove it (`cache-control` is derived; CSP is `createServer({ csp })`) |
35
36
  | HZ039 | `basePath` is not `''` or `/segment[/segment…]` | e.g. `'/shop'`, no trailing slash |
@@ -37,8 +38,34 @@ around the rule.
37
38
  | HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
38
39
  | HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
39
40
  | HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
40
- | HZ045 | `serve.ts` misses the widget bundle or the session store | add `widgets: await bundleWidgets(build)` / `session: sessionCookie(…)` |
41
- | HZ046 | an endpoint path is reserved, has params, or collides with a page, redirect or endpoint | use a static path such as `/api/…` (patch) |
41
+ | HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use client components and `app()` has no bundle | `export default app({ resolvers, components: bundleComponents })` |
42
+ | HZ046 | an endpoint path is reserved, has params or collides; an error without a status; a form posting to it with another method or an undeclared field | a static path such as `/api/…` (patch); map every error in `failed` |
42
43
  | HZ047 | a `fn` body uses an imported name or `let` state (it is sent to the browser as source) | pass the value as input, or write it as a `const` helper in the module |
43
44
  | HZ048 | `seed` names a field the context lacks, has no machine or route, or two views on one page seed a machine | seed top-level context fields, on one view per page |
45
+ | HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch), or `'live'` for push |
46
+ | HZ050 | a `'live'` query has no tags | add the tags its writers invalidate, or use `'request'` |
47
+ | HZ051 | `head.failed` misses a declared error of the head query, or maps another one | choose per error: a route (303), `403`, `404` or `410` (an intent decision: no patch) |
48
+ | HZ052 | a route that no page renders | link to the endpoint with `ui.link(endpoint, input)` (patch), or add its `ui.page` |
49
+ | HZ053 | (runtime) an endpoint answered `text/html`: a 500 | make it a `ui.page`; statuses and redirects go through `head.failed` |
50
+ | HZ054 | `ui.dom.form` reads one value of a list field or of a repeated name | `ui.dom.formAll('name')` (patch) |
51
+ | HZ055 | a form read names no control of the form | the name it suggests (patch), or add the control |
52
+ | HZ056 | (warning) a submit button also sends on click | `name`/`value` on the button, read in submit; or `type: 'button'` |
53
+ | HZ057 | `hozu.lock.json` differs from the computed lock (new, removed or copy-only changes, contract maps, a missing or 0.7 file) | if intended, `hozu check --update-lock`, then list the accepted `now:` lines in your summary |
54
+ | HZ058 | (warning) contracts that fire only copy-only transitions and evaluate no guard | none needed: the lock entries it names review those transitions |
55
+ | HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`, a spread or `in` | make the helper a `part()`; for a global use an operator or a `fn()` |
56
+ | HZ060 | a page route starts with a locale segment (`/de/…` under `site.locales`) | rename the route (patch); the locale prefix is added for you |
57
+ | HZ061 | (warning) a form-fed event payload declares limits | move them to the mutation input |
58
+ | HZ062 | (warning) a GET endpoint declares `invalidates` | `method: 'POST'`, or keep it on purpose (e-mail links) |
59
+ | HZ063 | (warning) as HZ055, in a form holding `ui.html`, a client component or another view | as HZ055 |
60
+ | HZ064 | two contracts with identical IR | remove one (patch) |
61
+ | HZ070 | a component's render references a declaration (event, query, route, message…) | pass a `Send` through `on`, an `Href` prop, text as a prop or slot |
62
+ | HZ071 | a variant from data | make it a prop, styled through an attribute (`aria-pressed:`, `data-[x=y]:`) |
63
+ | HZ072 | a caller's `class` sets a property the component owns | declare a variant; a one-off ends with `!` (snippet) |
64
+ | HZ073 / HZ074 | `!` inside a component / a leading `!x` | remove it (patch in the tv config, snippet in the render) / write `x!` (patch) |
65
+ | HZ075 | (warning) a caller's inherited class (colour, font) is hidden by an inner element | a variant, or style the inner element |
66
+ | HZ076 | (warning) a component owns a margin | remove it (patch); outer spacing is the caller's |
67
+ | HZ077 | (warning) `!` on a property the component does not own | remove the `!` (patch) |
68
+ | HZ078 | the kit's `tv.ts` config differs from the design system | `hozu add kit <id> --sync` |
69
+ | HZ079 | two classes of one element set the same property | the patch: a complementary toggle, or remove the one that never wins |
70
+ | HZ080 | (warning) a `part()` view inlined by two features | the snippet: the same `ui.component` in a kit |
44
71
  | HZ042 | `site.locales` empty / missing `site.lang` / not a canonical tag, or `ui.alternate` of an undeclared locale | fix the list (`'zh-TW'`, not `'zh_tw'`) |
@@ -1,12 +1,24 @@
1
- # Endpoints (webhooks, JSON APIs, auth callbacks)
1
+ # Endpoints (webhooks, JSON APIs, auth callbacks, downloads)
2
2
 
3
3
  ```ts
4
- export const orderHook = endpoint({ method: 'POST', path: '/api/hooks/order',
5
- input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) // exported from model.ts
6
- implement(orderHook, ({ id }, { request, session, setSession, env }) => ({ received: id })) // in resolvers
4
+ export const exportNotes = endpoint({ method: 'GET', path: '/api/export', // exported from model.ts
5
+ input: z.object({ format: z.enum(['json', 'csv']).default('json') }), output: z.object({ notes: z.array(Note) }),
6
+ errors: { Unauthorized: z.object({ message: z.string() }) }, failed: { Unauthorized: 401 } })
7
+ implement(exportNotes, (input, { session, fail }) => // in the app's resolvers
8
+ session ? { notes: notesOf(session.user) } : fail('Unauthorized', { message: 'Sign in' }))
7
9
  ```
8
- - GET input comes from the query string, POST input from a JSON or form body; invalid input answers 400
9
- `{ message, fields }`. The output is validated and sent as JSON.
10
- - `output: 'response'`: return a web `Response` yourself (redirects, headers); `setSession(value)` adds the cookie.
11
- - Paths are static and outside pages, redirects and `/_hozu/` (HZ046, with a patch). Cross-site browser POSTs are
12
- rejected; server-to-server calls (no `Origin`) are accepted. `hozu get /api/x` tries one without a server.
10
+ - GET input comes from the query string (coerced by the schema), POST input from a JSON or form body. Invalid input
11
+ answers 400 `{ error: 'Invalid', message, fields }`; `fail(name, payload)` answers `failed[name]` with
12
+ `{ error, message, fields? }` (statuses 400 401 403 404 409 410 422 429; every declared error is mapped, HZ046).
13
+ - `output` is one of: a schema (validated, sent as JSON); `'redirect'`, returning `redirect(ui.link(route, params))`
14
+ from the context (303, with basePath); `'response'`, a web `Response` for files and protocol bodies that are
15
+ neither JSON nor HTML. A `text/html` response is a 500 with HZ053: a page is a `ui.page` with `head.failed`.
16
+ - `input: 'raw'` (POST only) skips parsing and gives the resolver `bytes` (a `Uint8Array`), for signed webhooks.
17
+ - `setSession(value)` works on GET too (auth callbacks); OIDC form_post callbacks use `output: 'redirect'`.
18
+ - `invalidates: (input) => [itemsTag()]` refreshes like a mutation's when the endpoint succeeds. A GET endpoint with
19
+ `invalidates` is HZ062 (warning).
20
+ - Links: `ui.link(getEndpoint, input)` is the URL of a GET endpoint (flat scalar input, HZ035);
21
+ `ui.form({ method: 'post', action: ui.link(postEndpoint) }, [...])` posts a native form to a POST endpoint, whose
22
+ input declares every field name (HZ046). Another feature links to it only when it is in `exports` and imported (HZ006).
23
+ - Paths are static and outside pages, redirects and `/_hozu/` (HZ046, with a patch). Responses carry `nosniff` and a
24
+ referrer policy. Cross-site browser POSTs are rejected; server-to-server calls (no `Origin`) are accepted.
@@ -0,0 +1,79 @@
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
+ ## How it works
7
+ - A feature is **declarations**: events, queries / mutations (the only side effects), one machine, views,
8
+ contracts. Builders record them as data (an IR) that is validated, then rendered on the server. Only views bound
9
+ to the machine ship JS.
10
+ - **Callbacks are ordinary TypeScript** (`render`, `guard`, `assign`, `navigate`, `ui.each` / `ui.query`
11
+ callbacks): `===`, `!==`, `<`, `&&`, `||`, `!`, `??`, `c ? a : b`, template strings, `+`, `-`, `.length`, and in
12
+ `assign`, `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(v)`, `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
13
+ Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
14
+ - A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
15
+ `done` / `failed` leave it.
16
+ - A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
17
+ `machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
18
+ - Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
19
+
20
+ ## Files
21
+ ```
22
+ hozu.config.ts project({ schema, app, site, routes, pages, features }) routes.ts route() declarations
23
+ features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
24
+ features/<name>/feature.ts feature({ declarations: [model, views] }) app.ts app({ resolvers })
25
+ ```
26
+ Relative imports end in `.ts`.
27
+
28
+ ## A feature in one screen
29
+ ```ts
30
+ // model.ts
31
+ export const Item = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
32
+ export const Add = event({ payload: z.object({ title: z.string() }) })
33
+ export const itemsTag = tag({ param: null })
34
+ export const listItems = query({ input: z.object({}), output: z.array(Item), scope: 'public',
35
+ freshness: 'static', tags: () => [itemsTag()] })
36
+ export const addItem = mutation({ input: z.object({ title: z.string().min(2, 'Too short') }), output: Item,
37
+ errors: { Duplicate: z.object({ title: z.string() }) }, invalidates: () => [itemsTag()] })
38
+ export const items = machine({
39
+ context: z.object({ draft: z.string(), error: z.string().nullable() }),
40
+ initialContext: { draft: '', error: null },
41
+ initial: 'idle',
42
+ states: ({ ctx }) => ({
43
+ idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title; ctx.error = null } })] },
44
+ adding: {
45
+ invoke: invoke(addItem, {
46
+ input: { title: ctx.draft },
47
+ done: { target: 'idle', assign: () => { ctx.draft = '' } },
48
+ failed: {
49
+ Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
50
+ Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
51
+ },
52
+ }),
53
+ },
54
+ }),
55
+ })
56
+ // views.ts
57
+ export const Board = ui.view({
58
+ machine: items,
59
+ render: ({ ctx, when }) =>
60
+ ui.main({ class: 'mx-auto max-w-xl' }, [
61
+ ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
62
+ ui.input({ name: 'title', required: true, value: ctx.draft }),
63
+ ui.button({ type: 'submit' }, ['Add']),
64
+ ]),
65
+ ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
66
+ when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
67
+ ui.query(listItems, {}, {
68
+ ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
69
+ failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
70
+ }),
71
+ ]),
72
+ })
73
+ // feature.ts
74
+ import * as model from './model.ts'
75
+ import * as views from './views.ts'
76
+ export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
77
+ ```
78
+ Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
79
+ `implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
@@ -1,8 +1,8 @@
1
1
  # Forms
2
2
 
3
- - **Works without JavaScript** when the submit payload reads only `ui.dom.form('name')`, literals, context, params
4
- and search (else HZ036 warns): the server runs the same machine for a native post, then redirects or re-renders
5
- with the result. Put every value the submit needs in a named field (a `<select name="kind">`).
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): the server runs the same machine for a native post, then
5
+ redirects or re-renders with the result. Put every value the submit needs in a named field (a `<select name="kind">`).
6
6
  ```ts
7
7
  ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
8
8
  ui.label({ for: 'title' }, ['Title']),
@@ -22,3 +22,14 @@ ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
22
22
  - **Per-item actions without JS:** wrap each button in its own small form.
23
23
  - **Enum from a select:** `ui.dom.form('kind')` or `ui.dom.value` fills an enum field only when every literal
24
24
  option value is a member (HZ033).
25
+ - **Several values:** `ui.dom.formAll('ids')` is every value of the name in tree order (`[]` when none) for checkbox
26
+ groups, `select multiple` and controls inside `ui.each`, into a list field; `ui.dom.form(name)` is the first (HZ054).
27
+ - **Which button:** give submit buttons `name` and a literal `value` and read `ui.dom.form('action')` in the form's
28
+ submit; JS and no-JS read the same value. Into an enum only when every submit button of the form has that name and
29
+ a member value, else make the field nullable (HZ033). No `on.click` on a submit button (HZ056).
30
+ - **Controls outside the form** (forms cannot nest): `const bulk = ui.formRef()` at module level,
31
+ `ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
32
+ - **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
33
+ `z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
34
+ - **Invalid without JS:** a native post whose payload or mutation input fails re-renders with 400 through
35
+ `failed.Invalid`, like the JS submit. Keep limits out of the event payload (HZ061).
@@ -12,6 +12,6 @@ http: {
12
12
  headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
13
13
  },
14
14
  ```
15
- Server options: `createServer({ build, styles, resolvers, session?, widgets?, onError?, csp?, images?, og?, preview? })`.
15
+ Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
16
16
  A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
17
17
  There are no rewrites: one URL has one owner. For your own HTTP routes, see `hozu docs endpoints`.
@@ -1,11 +1,16 @@
1
1
  # Languages
2
2
 
3
- - `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`); routes and
4
- `ui.link` stay locale-free; `/` redirects by `Accept-Language`. `<html lang>`, hreflang and the sitemap are derived.
5
- - `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`
3
+ - **The URL holds the language.** Reload, sign-in and sign-out keep it, because every link and `navigate` resolves
4
+ `ui.link` in the page's locale. Do not add a cookie, a session field or a server rewrite for the language.
5
+ - `site: { lang: 'en', locales: ['en', 'de'], … }`: the default locale keeps its URLs (`/posts/a`), the others are
6
+ prefixed (`/de/posts/a`); `/en/posts/a` answers 308 `/posts/a`. There is no `Accept-Language` redirect: a new visit
7
+ to an unprefixed URL shows `site.lang`. Routes and `ui.link` stay locale-free; contracts expect the default URLs.
8
+ `<html lang>`, hreflang and the sitemap are derived. A page route starting with a locale segment is HZ060.
9
+ - A language switch is a link: `ui.a({ href: ui.alternate('de') }, ['Deutsch'])`, or
10
+ `locale === 'en' ? ui.alternate('de') : ui.alternate('en')`.
11
+ - `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
6
12
  exported from a module the feature lists; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
7
13
  needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
8
14
  - Machines never hold translated text (HZ041): store a code and choose the message in the view.
9
15
  - `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
10
- `ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view; `ui.alternate('zh-TW')` links the
11
- current page in another language.
16
+ `ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view.
@@ -33,7 +33,7 @@ export const m = machine({
33
33
  `ctx.list = ctx.list.filter((i) => i.id !== e.id)`. Values are event (`e`), result (`r`) or error fields,
34
34
  context, literals, operators and `fn()` calls.
35
35
  - **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
36
- - **navigate** sends the browser to `ui.link(route, params, search)` after the transition.
36
+ - **navigate** sends the browser to `ui.link(route, params, search?)` after the transition.
37
37
  - `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
38
38
  - **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
39
39
  and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
@@ -21,9 +21,10 @@ export default project({
21
21
  ui.page(itemPage, {
22
22
  views: [Detail],
23
23
  head: {
24
- query: getItem, // its failure sets the status (NotFound → 404)
24
+ query: getItem,
25
25
  input: (params) => ({ id: params.id }),
26
26
  render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
27
+ failed: { NotFound: 404 }, // every declared error of the query (HZ051)
27
28
  },
28
29
  entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
29
30
  }),
@@ -32,8 +33,21 @@ export default project({
32
33
  })
33
34
  ```
34
35
  - `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image` (a URL, `ui.asset(...)`
35
- or `ui.og({ title })`), `published`, `noindex`. `head.redirects: { Unauthorized: login }` maps errors to routes.
36
+ or `ui.og({ title })`), `published`, `noindex`.
37
+ - `head.failed` maps each declared error of the head query to a route without params (303) or to `403`, `404` or
38
+ `410`: `failed: { Unauthorized: login, Forbidden: 403 }`. It is exhaustive (HZ051); `Unexpected` is always 500.
39
+ It maps declared errors only: a head query that always fails is not a redirect.
40
+ - **Which redirect** (one per purpose):
41
+
42
+ | Need | Form |
43
+ |---|---|
44
+ | a static path moved | `http.redirects` (`hozu docs http`) |
45
+ | this visitor may not see the page | `head.failed` |
46
+ | a decision on success, e.g. `/` by session | a GET endpoint with `output: 'redirect'` (`hozu docs endpoints`) |
47
+ | after a machine transition | `navigate` |
48
+
49
+ - A route no page renders is HZ052; link to an endpoint with `ui.link(endpoint, input)` instead.
36
50
  - A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
37
51
  failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
38
- - A page loads JS only when a machine-bound part renders on it (`hozu plan <route>`). A view with a machine listed
39
- on several pages, in the same order, keeps its DOM and state across links when it never reads `params` / `search`.
52
+ - A page loads JS only when a machine-bound part renders on it (`hozu plan <route>`). Every link loads a document;
53
+ state across pages lives in the URL (`seed`), on the server (queries) or in a client component's own storage.
@@ -45,7 +45,25 @@ ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
45
45
  // machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
46
46
  // toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
47
47
  ```
48
- Try it without a server: `hozu post / --field title=x --next 'POST / id=i1&@Done' --next /`.
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`.
49
67
  - **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
50
68
  - **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
51
69
  - **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
@@ -33,7 +33,7 @@ Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `ite
33
33
  - The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
34
34
  - **server:**
35
35
  `implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
36
- - **Try it:** `hozu post / --button 'Clear done' --next /`.
36
+ - **Try it:** `hozu browse / --do 'click Clear done'` (with and without JS).
37
37
 
38
38
  ## A field shown on the detail page
39
39
  In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
@@ -1,21 +1,46 @@
1
1
  # Testing
2
2
 
3
- - **Without a server** (no need to start one): `hozu get /path --select 'button[aria-pressed=true]' --forms` and
4
- `hozu post / --field title=A --next 'POST / @Delete' --next /`.
5
- - `post` submits like a browser **without JavaScript** (a native form post), so it also checks no-JS behaviour.
6
- - Each step prints its status, redirect and `set-cookie` attributes (`HttpOnly`, `SameSite`); the session cookie
7
- is kept across `--next` steps. Two users: run two commands.
8
- - Endpoints: `hozu get '/api/items?x=1'`.
9
- - **In a real browser, still without a server:** `hozu browse / --do 'fill Search=park' --do 'click Tech Park'`.
10
- - It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose), loads the page, waits for
11
- hydration and runs the steps in order: `fill <label>=<value>`, `select <label>=<option>`, `check <label>`,
12
- `click <name>`, `press <key>`, `wait <ms>`, `goto <path>`. Labels and names are what a user reads (aria-label,
13
- `<label>`, placeholder, button text, `title`).
14
- - It prints the uncaught exceptions, `console.error`s and failed requests, every widget on the page (mounted,
15
- failed, size, canvases), the visible text and `--select <css>` elements; exit code 1 when anything failed.
16
- - `--screenshot shot.png` saves the viewport (open it to look); `--reduced-motion` emulates reduced motion.
17
- - Use it once after client-side work (widgets, islands); `get` / `post` stay the fast checks.
18
- - In code: `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` →
19
- `{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form.
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 client component 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 client components 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.