create-hozu 0.4.2 → 0.5.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 closures)
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,7 @@ 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) |
39
42
  | 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,12 @@
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 code: `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` →
10
+ `{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form.
11
+ - Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
12
+ - 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,25 @@
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.
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.
@@ -1,177 +0,0 @@
1
- # Hozu reference
2
-
3
- Open the section the task needs. The core API is in `SKILL.md`.
4
-
5
- ## Routes
6
- ```ts
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 (string), `:x?` optional (nullable string), `:x+` one or more / `:x*` zero or more (string[])
10
- (HZ024).
11
- - `search`: a flat object of scalars or enums, each with a default or nullable (HZ035); `null` = no query string.
12
- - URLs are canonical: keys sorted, defaults left out. `ui.link(home, null, { show: 'unread' })`. Changing `search`
13
- is a navigation, so a filter in the URL is a plain link and needs no machine.
14
-
15
- ## Views: events and DOM fields
16
- - Any DOM event name, plus `visible` (the element entered the viewport).
17
- - `ui.dom.value`: text. Into an enum field only from a `<select>` whose literal option values are all members
18
- (HZ033).
19
- - `ui.dom.form('name')`: a named field of the submitted form (on `submit`; the browser runs `required` /
20
- `minlength` first). Into an enum field when the name belongs to a `<select>` (or radios) in the form whose
21
- literal option values are all members, so one submit carries a title and a priority.
22
- - `ui.dom.valueAsNumber` (number | null), `ui.dom.checked`, `ui.dom.key`, and similar event fields.
23
- - Also: `ui.html(value)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png', import.meta.url))`,
24
- `ui.window({ on })` / `ui.document({ on })` for global listeners, widgets (see Widgets) for browser APIs and
25
- third-party DOM libraries.
26
-
27
- ## Widgets (browser APIs, DOM libraries)
28
- There is no `widget` export: declare with `ui.widget`, place with `ui.use`, list the declaration in the feature's
29
- `declarations`, and implement it in a client module.
30
- ```ts
31
- export const Copy = ui.widget({ tag: 'button', props: z.object({ text: z.string() }),
32
- events: { copied: z.object({}) }, client: new URL('./copy.client.ts', import.meta.url), load: 'visible', wraps: true })
33
- ui.use(Copy, { props: { text: block.code }, on: { copied: () => ui.send(Copied, {}) }, class: 'btn' }, ['Copy'])
34
- ```
35
- ```ts
36
- // copy.client.ts: a type-only import of the declaration
37
- import { implement } from '@hozu/core/widget'
38
- import type { Copy } from './widgets.ts'
39
- export default implement<typeof Copy>(({ el, props, emit, signal }) => {
40
- el.addEventListener('click', () => navigator.clipboard.writeText(props.text).then(() => emit('copied', {})), { signal })
41
- return { update(next) { props = next } }
42
- })
43
- ```
44
- `load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
45
- **Serving them is a separate step.** Run `npm install @hozu/bundle`, then pass `widgets: await bundleWidgets(build)`
46
- to `createServer` in `serve.ts` (and to `exportStatic`). Without it the server refuses to start; `hozu build` bundles
47
- widgets itself. HZ029 is a client module that does not bundle. A library's own CSS goes in `app.css`
48
- (`@import "leaflet/dist/leaflet.css";`), and a map or chart host needs a height class (`h-96`).
49
-
50
- ## Forms without JavaScript
51
- A submit whose payload reads only `ui.dom.form('name')`, literals, context, params and search also works without JS
52
- (otherwise HZ036 warns). The server runs the same machine and mutation, then redirects (on `navigate`, or when the
53
- machine is back where it started) or re-renders the page with the result (for example an error alert). Put every
54
- value the submit needs in named fields: a `<select name="kind">`, not a separate change event.
55
-
56
- ## Field errors (`Invalid`)
57
- Every mutation also has the framework error `Invalid` = `{ message, fields }`: one key per top-level input field
58
- (`string | null`). It is returned when the input fails its schema (put limits there:
59
- `z.string().min(2, 'Use at least 2 characters')`), and a resolver can return it:
60
- `fail('Invalid', { message, fields: { title: 'Already taken' } })`.
61
- - `failed.Invalid` is optional (without it, `Unexpected` handles it).
62
- - With it: `assign: (e) => [op.set(ctx.fields, e.fields)]`, and show `ctx.fields.title` under the input with
63
- `'aria-invalid': op.neq(ctx.fields.title, null)`.
64
- - Never declare errors named `Invalid` or `Unexpected` yourself (HZ014).
65
-
66
- ## Pages
67
- `ui.page(route, { views, head, entries?, assert? })`.
68
- - `head.render` returns `{ title, description?, type?: 'website' | 'article', image?, published?, noindex? }`.
69
- `head.query` + `head.input: (params, locale) => …` load data for it; a failing head query sets the HTTP status
70
- (NotFound → 404). `head.redirects` maps declared errors to routes.
71
- - `entries: { query, input, params: (item) => … }` lists the pages of a route with params for the sitemap.
72
- - `project({ notFound: route, error: route })` renders those pages for 404 / 500.
73
- - A view listed with a machine on several pages, in the same order, stays mounted when links move between them
74
- (see `patterns.md`).
75
- - **JS per page is derived:** a page loads the client only when a machine-bound node renders on it. An island
76
- inside `ui.each`, `ui.if`, `when` or a query branch loads it only on pages where it renders; `hozu plan <route>`
77
- says `always` or `only when rendered`. Do not add views or flags to avoid JS.
78
-
79
- ## Sessions
80
- - **Start from the scaffold:** `hozu add feature notes --page / --with auth` writes `features/account`
81
- (sign-in page, sign-out, `me`), the session cookie in `serve.ts`, per-user resolvers, and a redirect to `/login`
82
- when signed out. Replace the name-only sign-in with real credentials before production; set `SESSION_SECRET`
83
- (and `SESSION_SECURE=true` behind HTTPS).
84
- - `project({ session: z.object({ user: z.string() }) })` declares the identity. Queries with `scope: 'user'` and
85
- mutations receive `session`; public resolvers never do.
86
- - `createServer({ session: (request) => value })`, or `sessionCookie({ name, secret })` from
87
- `@hozu/runtime-server` for a signed cookie. Mutations can call `setSession(value)`.
88
-
89
- ## Languages (i18n)
90
- - `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`). Routes and
91
- `ui.link` stay locale-free; links keep the current locale. `/` and locale-less URLs redirect by
92
- `Accept-Language`. `<html lang>`, hreflang, og:locale and the sitemap are derived.
93
- - Text: `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`,
94
- added to the feature's `declarations`. Use `text.title` or `text.saved({ count })` in views and `head.render`.
95
- Every locale needs every key with the same `{placeholders}` (HZ040). Plurals:
96
- `'{n, plural, =0 {none} one {# item} other {# items}}'`; `select` also works.
97
- - Machines never hold translated text (HZ041): store a code (`'duplicate'`) and pick the message in the view.
98
- - `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
99
- `ui.format.relative(n, 'day')`, `ui.format.list(xs)`.
100
- - `locale` is in every view scope and the second argument of `head.input`. `ui.alternate('zh-TW')` is the current
101
- page in another locale.
102
-
103
- ## Environment
104
- `project({ env: { server: z.object({ DB_URL: z.string() }), public: z.object({ SUPPORT_EMAIL: z.string().email() }) } })`.
105
- Both are parsed when the server starts (defaults and `z.coerce` apply; a missing value stops startup). Resolvers get
106
- `ctx.env` (server values). Views read public values with `ui.env(PublicEnv).SUPPORT_EMAIL`. Machines cannot read env
107
- (HZ041).
108
-
109
- ## HTTP
110
- Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
111
- ```ts
112
- http: {
113
- basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
114
- trailingSlash: 'always', // or 'never'; the other form answers 308
115
- redirects: { // keyed by the old path; never a path a page owns (HZ037)
116
- '/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true }, // 308
117
- '/docs': { to: 'https://docs.example.com', permanent: false }, // 307
118
- },
119
- headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // or routes: [post]; not cache-control (HZ038)
120
- },
121
- ```
122
- There are no rewrites: one URL has one owner.
123
-
124
- ## Server options
125
- `createServer({ build, styles, resolvers, session?, onError?, csp?, images?, og?, preview? })` from
126
- `@hozu/adapter-node`.
127
- - `onError(error, { effect | path })` receives every unexpected failure.
128
- - A strict CSP, `nosniff` and a cross-site POST check are on by default (`csp` adds sources, e.g.
129
- `{ script: ['https://analytics.example'] }`, or `false`).
130
- - Test a mutation with curl:
131
- `curl -X POST localhost:4700/_hozu/effect -H 'content-type: application/json' -d '{"effect":"items.addItem","input":{"title":"x"},"keys":[]}'`.
132
-
133
- ## Content, images, share images, fonts
134
- - **Markdown:** `@hozu/content` turns `content/posts/*.md` (YAML front matter checked by a schema) into
135
- `{ slug, data, html, headings }`: `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`
136
- in `server.ts`, returned from ordinary query resolvers; render the body with `ui.html(post.html)`.
137
- - **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
138
- dimensions). With `@hozu/image` installed, pass `images: await optimizeImages(build)` to `createServer` (and
139
- `hozu build` does it itself): raster assets get WebP `srcset` widths and `sizes`.
140
- - **Share images:** `head.render` → `image: ui.og({ title, subtitle })` renders a 1200×630 card; pass
141
- `og: ogImage` (from `@hozu/image`) to `createServer`. On a static host, use a file instead:
142
- `image: ui.asset(new URL('./share.png', import.meta.url))` (made absolute with `site.url`).
143
- - **Fonts:** a local `@font-face` gets a size-matched `"<Family> Fallback"` automatically.
144
- - **Page transitions:** the stylesheet turns on cross-document view transitions, so links between pages cross-fade
145
- instead of flashing (no JS). Turn them off with `@view-transition { navigation: none; }` in `app.css`; style them
146
- with `::view-transition-*`.
147
-
148
- ## Preview (drafts)
149
- `createServer({ preview: { secret } })`; `GET /_hozu/preview?secret=…&path=/posts/a` turns preview on (a signed
150
- cookie), `/_hozu/preview/exit` turns it off. Resolvers get `ctx.preview`; preview responses are never cached and are
151
- noindex.
152
-
153
- ## PWA and offline
154
- A web app manifest is derived from `site` (`name`, `themeColor`, `icon`). `site.offline: route` is a static page
155
- shown when the network is down; a service worker is generated (HZ043: no params, no per-request data).
156
-
157
- ## Testing rendered pages
158
- `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` gives
159
- `{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form. In a browser test (Playwright),
160
- wait for `html[data-hozu-ready]` before clicking: it is set when the page has hydrated.
161
-
162
- ## Deployment
163
- `hozu build` writes `dist/public/` (static files for any host or CDN) and `dist/manifest.json`. On Node:
164
- `createServer({ build: buildProject(project, { manifest }), manifest, publicDir: 'dist/public', … })`. On Bun, Deno,
165
- Cloudflare Workers or Vercel the server is `createHandler({ build, manifest, resolvers, render })` from
166
- `@hozu/runtime-server` with `export default { fetch: handler.fetch }`, where
167
- `import * as render from './dist/server/render.js'` is the page code `hozu build` generates (edge runtimes cannot
168
- generate it at startup). Page cache and tag revalidation are per instance.
169
- A fully static site (GitHub Pages, any file host): `exportStatic({ build, styles, resolvers, outDir })` from
170
- `@hozu/adapter-static` writes every page without per-request data, plus the files they link to, and lists skipped
171
- routes.
172
- ```ts
173
- import manifest from './dist/manifest.json' with { type: 'json' }
174
- import * as render from './dist/server/render.js'
175
- const handler = createHandler({ build: buildProject(project, { manifest }), manifest, render, resolvers: createResolvers() })
176
- export default { fetch: handler.fetch }
177
- ```