create-hozu 0.4.2 → 0.6.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,21 @@
1
+ # Contracts
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.
6
+ ```ts
7
+ export const addsValid = contract(m, {
8
+ given: { state: 'idle' }, // context defaults to initialContext
9
+ when: [
10
+ { send: Add, payload: { title: 'Milk' } },
11
+ { done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
12
+ ], // or { failed: addItem, error: 'Duplicate', data } / { elapse: ms }
13
+ expect: {
14
+ state: 'idle',
15
+ changes: { draft: '' }, // only what changes; nested objects are patches
16
+ effects: [{ effect: addItem, input: { title: 'Milk' } }, { navigate: '/items/i9' }], // default: none
17
+ },
18
+ })
19
+ ```
20
+ Add contracts to the feature's `declarations`. When a contract fails (HZ015), decide which is intended — the
21
+ machine or the contract — before changing either.
@@ -0,0 +1,35 @@
1
+ # Data: queries, mutations, tags, fn, resolvers
2
+
3
+ ```ts
4
+ export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
5
+ export const listItems = query({
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'
9
+ tags: () => [itemsTag()], // optional; (input) => [...]
10
+ })
11
+ export const getItem = query({ input: Key, output: Item, errors: { NotFound: Key }, scope: 'public',
12
+ freshness: 'static', tags: (k) => [itemTag(k.id)] })
13
+ export const addItem = mutation({
14
+ input: z.object({ title: z.string().min(2, 'Use at least 2 characters') }), output: Item,
15
+ errors: { Duplicate: z.object({ title: z.string() }) }, // optional: declared failures
16
+ invalidates: () => [itemsTag()], // refreshes queries with these tags
17
+ })
18
+ export const visible = fn({ // computation: pure JS, self-contained (no imports, no helpers outside impl: HZ047)
19
+ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
20
+ impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
21
+ })
22
+ ```
23
+ - Call a `fn` from views or machines with data: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
24
+ - Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
25
+ `scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
26
+ - **Resolvers** (`server.ts`, or `features/<name>/server.ts` from the scaffold):
27
+ ```ts
28
+ export const createResolvers = () => resolvers(project, (implement) => [
29
+ implement(listItems, () => items.map((i) => ({ ...i }))),
30
+ implement(getItem, ({ id }, { fail }) => items.find((i) => i.id === id) ?? fail('NotFound', { id })),
31
+ implement(addItem, ({ title }, { fail, session }) => /* … */ ),
32
+ ])
33
+ ```
34
+ - Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
35
+ `fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
@@ -0,0 +1,12 @@
1
+ # Deployment
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', … })`.
6
+ - **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.
@@ -9,14 +9,14 @@ around the rule.
9
9
  | HZ002 | event handled nowhere | handle it in a state or remove it |
10
10
  | HZ003 / HZ007 | unknown effect / reference | add it to `feature({ declarations })`, or fix the name (the patch suggests one) |
11
11
  | HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
12
- | HZ005 | a node sends an event in a state that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
12
+ | HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
13
13
  | HZ006 | crossing a feature boundary | import the feature and use its `exports` |
14
14
  | HZ008 | a path does not exist in the schema | fix the property name |
15
15
  | HZ009 | a guardless transition shadows later ones | put guarded transitions first |
16
- | HZ014 | wrong builder output | follow the builder signature |
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 without a contract | add the contract from the snippet |
19
- | HZ018 | behaviour changed without a contract change | update the contracts, then `--update-lock` |
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
20
  | HZ021 | a query or mutation without a resolver | `implement(...)` it in server.ts |
21
21
  | HZ022 | user data in a cacheable region | keep `scope: 'user'` queries out of cached pages |
22
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` |
@@ -36,4 +36,8 @@ around the rule.
36
36
  | HZ040 | a locale lacks a message, or uses other `{placeholders}` | add/translate the key in that locale |
37
37
  | HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
38
38
  | HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
39
+ | HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
40
+ | 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) |
42
+ | HZ047 | a `fn` body uses a helper or constant defined outside `impl` (it is sent to the browser as source) | write the helper inside `impl`, or pass the value as input |
39
43
  | 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'`) |
@@ -0,0 +1,12 @@
1
+ # Endpoints (webhooks, JSON APIs, auth callbacks)
2
+
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() }) }) // in declarations
6
+ implement(orderHook, ({ id }, { request, session, setSession, env }) => ({ received: id })) // in resolvers
7
+ ```
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.
@@ -0,0 +1,5 @@
1
+ # Environment
2
+
3
+ `project({ env: { server: z.object({ DB_URL: z.string() }), public: z.object({ SUPPORT_EMAIL: z.string().email() }) } })`.
4
+ Both are parsed at startup (defaults and `z.coerce` apply; a missing value stops startup). Resolvers read
5
+ `ctx.env`; views read public values with `ui.env(PublicEnv).SUPPORT_EMAIL`. Machines cannot read env (HZ041).
@@ -0,0 +1,24 @@
1
+ # Forms
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">`).
6
+ ```ts
7
+ ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
8
+ ui.label({ for: 'title' }, ['Title']),
9
+ ui.input({ id: 'title', name: 'title', required: true, minlength: 2, value: ctx.draft,
10
+ 'aria-invalid': ctx.fields.title !== null, 'aria-describedby': 'title-error',
11
+ on: { input: ui.send(Draft, { text: ui.dom.value }) } }),
12
+ ui.select({ name: 'kind', 'aria-label': 'Kind' }, kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k]))),
13
+ ui.button({ type: 'submit' }, ['Add']),
14
+ ])
15
+ ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
16
+ ```
17
+ - **Field errors:** context `fields: z.object({ title: z.string().nullable() })`, reset on submit
18
+ (`ctx.fields = { title: null }`), and `failed.Invalid: { target: 'idle', assign: (e) => { ctx.fields = e.fields } }`.
19
+ Limits live in the mutation's input schema: `z.string().min(2, 'Use at least 2 characters')`.
20
+ - **Server error:** a declared error sets `ctx.error`; show `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`.
21
+ - **Clear after success:** bind `value: ctx.draft` and reset it in `done`.
22
+ - **Per-item actions without JS:** wrap each button in its own small form.
23
+ - **Enum from a select:** `ui.dom.form('kind')` or `ui.dom.value` fills an enum field only when every literal
24
+ option value is a member (HZ033).
@@ -0,0 +1,17 @@
1
+ # HTTP
2
+
3
+ Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
4
+ ```ts
5
+ http: {
6
+ basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
7
+ trailingSlash: 'always', // or 'never'; the other form answers 308
8
+ redirects: { // keyed by the old path; never a path a page owns (HZ037)
9
+ '/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true },
10
+ '/docs': { to: 'https://docs.example.com', permanent: false },
11
+ },
12
+ headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
13
+ },
14
+ ```
15
+ Server options: `createServer({ build, styles, resolvers, session?, widgets?, onError?, csp?, images?, og?, preview? })`.
16
+ A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
17
+ There are no rewrites: one URL has one owner. For your own HTTP routes, see `hozu docs endpoints`.
@@ -0,0 +1,11 @@
1
+ # Languages
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} 筆' } })`
6
+ in the feature's `declarations`; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
7
+ needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
8
+ - Machines never hold translated text (HZ041): store a code and choose the message in the view.
9
+ - `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.
@@ -0,0 +1,40 @@
1
+ # Machine (one per feature)
2
+
3
+ ```ts
4
+ export const m = machine({
5
+ context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
6
+ initialContext: { draft: '', error: null, target: '' },
7
+ initial: 'idle',
8
+ states: ({ ctx }) => ({
9
+ idle: {
10
+ on: [
11
+ on(Draft, { target: 'idle', assign: (e) => { ctx.draft = e.text } }),
12
+ on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
13
+ on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
14
+ on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
15
+ ],
16
+ },
17
+ adding: { // runs addItem on entry; drops events it does not handle
18
+ invoke: invoke(addItem, {
19
+ input: { title: ctx.draft },
20
+ done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
21
+ failed: { // every declared error + Unexpected (+ optional Invalid)
22
+ Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
23
+ Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
24
+ },
25
+ }),
26
+ },
27
+ removing: { invoke: invoke(removeItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) },
28
+ flash: { after: [{ ms: 3000, target: 'idle' }], ignore: [Add] }, // timers; ignore only without invoke
29
+ }),
30
+ })
31
+ ```
32
+ - **assign** writes context: `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(item)`,
33
+ `ctx.list = ctx.list.filter((i) => i.id !== e.id)`. Values are event (`e`), result (`r`) or error fields,
34
+ context, literals, operators and `fn()` calls.
35
+ - **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
36
+ - **navigate** sends the browser to `ui.link(route, params, search)` after the transition.
37
+ - `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
38
+ - A transition to the same state re-enters it and re-runs its `invoke`: do not handle the busy event in the busy
39
+ state. Machines never hold translated text (store a code, choose the message in the view).
40
+ - Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
@@ -0,0 +1,39 @@
1
+ # Routes and pages
2
+
3
+ ```ts
4
+ // routes.ts
5
+ export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
6
+ export const itemPage = route({ path: '/items/:id', params: z.object({ id: z.string() }), search: null })
7
+ export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.array(z.string()).min(1) }), search: null })
8
+ ```
9
+ - `:x` one segment, `:x?` optional (nullable), `:x+` / `:x*` one-or-more / zero-or-more (string[]) (HZ024).
10
+ - `search`: flat scalars or enums, each with a default or nullable (HZ035). URLs are canonical (keys sorted,
11
+ defaults left out). Changing `search` is a navigation: a filter in the URL is a plain `ui.link`, no machine.
12
+
13
+ ```ts
14
+ // hozu.config.ts
15
+ export default project({
16
+ schema: zodAdapter, styles: new URL('./app.css', import.meta.url),
17
+ site: { url: 'https://example.com', name: 'Items', lang: 'en' },
18
+ routes: { home, itemPage }, notFound: missing, // notFound / error: routes rendered for 404 / 500
19
+ pages: [
20
+ ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } }),
21
+ ui.page(itemPage, {
22
+ views: [Detail],
23
+ head: {
24
+ query: getItem, // its failure sets the status (NotFound → 404)
25
+ input: (params) => ({ id: params.id }),
26
+ render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
27
+ },
28
+ entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
29
+ }),
30
+ ],
31
+ features: [items],
32
+ })
33
+ ```
34
+ - `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
+ - A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
37
+ 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`.
@@ -0,0 +1,45 @@
1
+ # Common UI patterns
2
+
3
+ Each pattern is complete here; there is no need to open other files.
4
+
5
+ - **Busy state:** render every control once; the state with `invoke` drops repeated submits. Progress:
6
+ `when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
7
+ - **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
8
+ and the refreshed query shows the real item.
9
+ - **Filter and empty state** (in context): two `fn`s over the list:
10
+ ```ts
11
+ export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
12
+ impl: ({ items, show }) => items.filter((i) => show === 'all' || (show === 'done') === i.done) })
13
+ export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
14
+ impl: ({ items, show }) => !items.some((i) => show === 'all' || (show === 'done') === i.done) })
15
+ // view
16
+ isEmpty({ items, show: ctx.show })
17
+ ? ui.p({ class: 'text-slate-500' }, ['No items'])
18
+ : ui.ul({}, [ui.each(visible({ items, show: ctx.show }), 'id', (i) => ui.li({}, [i.title]))])
19
+ ```
20
+ - **Search as you type:** context `search: z.string()`; `ui.input({ type: 'search', 'aria-label': 'Search', value:
21
+ ctx.search, on: { input: ui.send(Search, { text: ui.dom.value }) } })`; `on(Search, { target: 'idle', assign: (e) =>
22
+ { ctx.search = e.text } })`; filter with a `fn({ input: z.object({ items, text: z.string() }), … })`.
23
+ - **Toggle buttons:** for each option of a constant list,
24
+ `ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
25
+ - **Filter in the URL** (shareable, no JS): `search` on the route, options as
26
+ `ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
27
+ - **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
28
+ ```ts
29
+ ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
30
+ ui.input({ type: 'hidden', name: 'id', value: item.id }),
31
+ ui.button({ type: 'submit' }, [item.done ? 'Reopen' : 'Done']),
32
+ ])
33
+ // machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
34
+ // toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
35
+ ```
36
+ Try it without a server: `hozu post / --field title=x --next 'POST / id=i1&@Done' --next /`.
37
+ - **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
38
+ - **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
39
+ - **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
40
+ - **Detail page with a 404:** `hozu docs pages`.
41
+ - **UI kept across links** (a cart, a player): list the same machine view on each page, in the same order.
42
+ - **Load more:** context `{ cursors: [null], last: null }`;
43
+ `ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
44
+ (`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
45
+ `More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
@@ -0,0 +1,21 @@
1
+ # Testing
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.
20
+ - Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
21
+ - Browser tests: wait for `html[data-hozu-ready]` (set after hydration) before clicking.
@@ -0,0 +1,34 @@
1
+ # Views
2
+
3
+ ```ts
4
+ export const Board = ui.view({
5
+ machine: m, // optional: without it, no ctx / when / events, and 0 JS
6
+ route: home, // optional: render gets { params, search } typed by the route
7
+ render: ({ ctx, when, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
8
+ })
9
+ ```
10
+ - **Elements:** `ui.<tag>(attrs, children)` for every HTML and SVG element; void tags (`input`, `img`) take only attrs.
11
+ Children are nodes, strings, numbers, data values, and `null` / `false` (render nothing).
12
+ - **Attributes:** HTML names in lower case (`for`, `minlength`, `aria-pressed`, `data-x`), typed per tag. Values are
13
+ literals or data: `'aria-pressed': ctx.show === 'all'`, `title: ctx.error ?? 'OK'`.
14
+ - **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026). Conditional classes:
15
+ `toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. CSS variables: `vars: { '--hue': item.hue }`. No `style`.
16
+ - **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`,
17
+ `list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`. With an enter/leave animation:
18
+ `ui.if(cond, [then], [else], 'fade')`.
19
+ - **By machine state:** `when(['adding', 'saving'], [ui.p({}, ['Saving…'])])`.
20
+ - **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`; `ui.each(tags, null, (t) => …)` for primitives.
21
+ Never `.map` over data (only over constants: `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`).
22
+ - **Text:** template strings work: `` `${n} items` ``.
23
+ - **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')`, `ui.dom.checked`, `ui.dom.valueAsNumber`,
25
+ `ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field only from a `<select>` or radios whose literal
26
+ option values are all members (HZ033).
27
+ - **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`: `ui.link(home, null, { show: 'done' })`.
29
+ - **Data:** `ui.query(listItems, input, { ready: (items) => …, pending: ui.p({}, ['Loading…']), failed: { NotFound:
30
+ () => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`.
31
+ Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
32
+ invalidates refresh in place.
33
+ - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
34
+ import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
@@ -0,0 +1,28 @@
1
+ # Widgets (browser APIs, DOM libraries)
2
+
3
+ Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the `serve.ts` bundle
4
+ and the `@hozu/bundle` dependency. There is no `widget` export; the pieces are:
5
+ ```ts
6
+ export const Map = ui.widget({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
7
+ events: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
8
+ load: 'visible', wraps: false }) // in declarations
9
+ ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
10
+ class: 'h-96 w-full' }, []) // in a view
11
+ ```
12
+ ```ts
13
+ // map.client.ts: a type-only import of the declaration
14
+ import { implement } from '@hozu/core/widget'
15
+ import type { Map } from './widgets.ts'
16
+ export default implement<typeof Map>(({ el, props, emit, signal }) => {
17
+ const map = createMap(el, props) // any DOM library
18
+ map.on('pick', (id) => emit('picked', { id }))
19
+ return { update(next) { map.move(next) }, destroy() { map.remove() } }
20
+ })
21
+ ```
22
+ - `load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
23
+ - `serve.ts` passes `widgets: await bundleWidgets(build)` (the server refuses to start without it; `hozu build` bundles
24
+ them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
25
+ height class.
26
+ - Check it with `hozu browse /` (no server): each widget is listed as mounted / failed / not mounted with its size
27
+ and canvases, next to any error it threw. A mounted host carries `data-hozu-widget="<feature>.<Name>"` and
28
+ `data-hozu-widget-state="mounted"` for your own browser tests.
@@ -16,6 +16,7 @@ __RUN__ hozu check # after every change: types, rules, c
16
16
  __RUN__ hozu check --update-lock # only to accept a clean, intended behaviour change
17
17
  __RUN__ hozu get / --select button --forms # try pages without a server: text, attributes, forms
18
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
19
20
  ```
20
21
  __NOTE__
21
22
  ## Rules
package/skill/patterns.md DELETED
@@ -1,57 +0,0 @@
1
- # Hozu patterns
2
-
3
- Patterns marked *(example)* are used in `example/features/bookmarks/model.ts` and `views.ts`, next to this file;
4
- read only the part you need.
5
-
6
- - **Form with a server-side error** *(example)*:
7
- - `ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [label, input, button])`.
8
- - The machine goes to `adding`, which invokes the mutation. `failed.Duplicate` sets `ctx.error`.
9
- - Show the error with `ui.if(op.neq(ctx.error, null), [ui.p({ role: 'alert' }, [ctx.error])], [])`.
10
- - To clear the input after success, bind `value: ctx.draft` and reset `draft` in `done`.
11
- - **Busy states (a mutation in flight)** *(example)*: render every control **once**. In each busy state, `ignore` the events
12
- those controls send. Do not duplicate controls under `when`. Handling them there would re-enter the busy state
13
- instead, and HZ005 would reject leaving them unhandled.
14
- - **Filtering and empty state** *(example)*: `ui.each(visible({ items, show: ctx.show }), 'id', …)` and
15
- `ui.if(isEmpty({ items, show: ctx.show }), [ui.p({}, ['No items'])], [ui.ul(...)])`, both using `fn`s.
16
- - **Toggle buttons** (`aria-pressed`): `'aria-pressed': op.eq(ctx.show, s.value)` plus
17
- `on: { click: ui.send(SetShow, { show: s.value }) }` for each option of a constant list.
18
- - **Per-item action** *(example)*:
19
- - `ui.send(ToggleRead, { id: item.id })` → a `toggling` state that stores `ctx.target` and invokes the mutation
20
- with `{ id: ctx.target }`.
21
- - Label text by data: `ui.if(op.eq(item.read, true), ['Mark unread'], ['Mark read'])`.
22
- - **Select bound to an enum**:
23
- `ui.select({ 'aria-label': 'Kind', on: { change: ui.send(PickKind, { kind: ui.dom.value }) } }, kinds.map((k) => ui.option({ value: k, selected: op.eq(ctx.kind, k) }, [k])))`,
24
- where the event payload is `{ kind: Kind }`, the zod enum.
25
- - **Detail page with a 404** *(example)*: a view with `route: itemPage` and no machine,
26
- `ui.query(getItem, { id: params.id }, { ready, pending: null, failed: { NotFound: () => ..., Unexpected: () => ... } })`,
27
- plus `head.query: getItem`.
28
- - **Refresh after a mutation** *(example)*: tag the query, and list the tag in the mutation's `invalidates`. A mutation can
29
- read only its input for tag params; use a list-wide tag when it affects many items.
30
-
31
- - **Filter in the URL** *(example)* (shareable, works without JS): declare `search` on the route, render the options as
32
- `ui.link(home, null, { show: s.value })` links with `'aria-current': op.eq(search.show, s.value)`, and filter with
33
- `fn`s over `search.show`. Only use machine context for filters that should not survive a reload.
34
- - **Go to what was just created** *(example)*: `done: [{ target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }]`.
35
- - **No-JS form** *(example)*: every value the submit needs is a named field read with `ui.dom.form('name')`; the server runs the
36
- machine for a native post. Per-item actions without JS: wrap the button in its own small form.
37
- - **UI that survives following a link** (a cart, a player, a chat box): list the same
38
- view with a machine on every page that should keep it, in the same order, e.g. `views: [ProductGrid, CartPanel]`
39
- and `views: [ProductDetail, CartPanel]`. Links between those pages then swap only the other views; the kept view's
40
- DOM and machine state stay. Nothing to declare: a view is kept only if it never reads `params`/`search` (neither
41
- in its tree nor in its machine). `hozu plan <route>` lists what is kept per target route. Style the loading
42
- state with `html[data-hozu-navigating]`.
43
- - **Two languages**: `site.locales`, one `ui.messages` per feature, a language switcher of
44
- `ui.a({ href: ui.alternate('en'), hreflang: 'en', lang: 'en' }, ['English'])` links, and `ui.format.date` for dates.
45
- - **Load more / infinite scroll**: context `{ cursors: [null], last: null }`;
46
- `ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => ... }))`; in the last page
47
- (`op.and(op.eq(cursor, ctx.last), op.neq(page.next, null))`) render a button with `on: { click: ui.send(More,
48
- { cursor: page.next }) }` and a sentinel `ui.div({ class: 'h-px', on: { visible: ui.send(More, …) } }, [])`.
49
- `More` appends the cursor and sets `last`, guarded by `op.and(op.neq(ctx.last, e.cursor), op.neq(e.cursor, null))`
50
- so a page loads once. It needs JS; a list that must work without JS pages through `search` links.
51
- - **Optimistic item** *(example)*: while the mutation runs, render the pending value from context
52
- in the busy state: `when(['adding'], [ui.p({ class: 'opacity-50', 'aria-busy': 'true' }, ['Adding ', ctx.draft, '…'])])`.
53
- Leaving the state (done or failed) removes it; the refreshed query shows the real item.
54
- - **Field errors** *(example)*: context `fields: z.object({ title: z.string().nullable(), kind:
55
- z.string().nullable() })`, reset it on submit, `failed.Invalid: [{ target: 'idle', assign: (e) => [op.set(ctx.fields,
56
- e.fields)] }]`, and render `ui.p({ id: 'title-error' }, [ctx.fields.title])` with `'aria-invalid': op.neq(ctx.fields.title,
57
- null)` on the input. It also works without JS.