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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +69 -154
- package/skill/changing.md +15 -18
- package/skill/example/features/bookmarks/model.ts +51 -23
- package/skill/example/features/bookmarks/views.ts +10 -14
- package/skill/topics/auth.md +12 -0
- package/skill/topics/content.md +14 -0
- package/skill/topics/contracts.md +21 -0
- package/skill/topics/data.md +35 -0
- package/skill/topics/deploy.md +12 -0
- package/skill/{diagnostics.md → topics/diagnostics.md} +8 -4
- package/skill/topics/endpoints.md +12 -0
- package/skill/topics/env.md +5 -0
- package/skill/topics/forms.md +24 -0
- package/skill/topics/http.md +17 -0
- package/skill/topics/i18n.md +11 -0
- package/skill/topics/machine.md +40 -0
- package/skill/topics/pages.md +39 -0
- package/skill/topics/patterns.md +45 -0
- package/skill/topics/testing.md +21 -0
- package/skill/topics/views.md +34 -0
- package/skill/topics/widgets.md +28 -0
- package/templates/guide.md +1 -0
- package/skill/patterns.md +0 -57
- package/skill/reference.md +0 -177
|
@@ -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
|
|
19
|
-
| HZ018 | behaviour changed
|
|
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.
|
package/templates/guide.md
CHANGED
|
@@ -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.
|