create-hozu 0.1.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,129 @@
1
+ import { event, fn, invoke, machine, mutation, on, op, query, tag, ui } from '@hozu/core'
2
+ import { z } from 'zod'
3
+ import { bookmarkPage, Show } from '../../routes.ts'
4
+
5
+ export const Kind = z.enum(['article', 'video', 'podcast'])
6
+ export const Bookmark = z.object({ id: z.string(), title: z.string(), kind: Kind, read: z.boolean() })
7
+ const Bookmarks = z.array(Bookmark)
8
+ const BookmarkKey = z.object({ id: z.string() })
9
+ const NewBookmark = z.object({
10
+ title: z.string().min(2, 'Use at least 2 characters').max(80, 'Use at most 80 characters'),
11
+ kind: Kind,
12
+ })
13
+
14
+ export const Draft = event({ payload: z.object({ text: z.string() }) })
15
+ export const Add = event({ payload: z.object({ title: z.string(), kind: Kind }) })
16
+ export const ToggleRead = event({ payload: BookmarkKey })
17
+
18
+ export const bookmarksTag = tag({ param: null })
19
+
20
+ export const listBookmarks = query({
21
+ input: z.object({}),
22
+ output: Bookmarks,
23
+ scope: 'public',
24
+ freshness: 'static',
25
+ tags: () => [bookmarksTag()],
26
+ })
27
+
28
+ export const getBookmark = query({
29
+ input: BookmarkKey,
30
+ output: Bookmark,
31
+ errors: { NotFound: BookmarkKey },
32
+ scope: 'public',
33
+ freshness: 'static',
34
+ tags: () => [bookmarksTag()],
35
+ })
36
+
37
+ export const addBookmark = mutation({
38
+ input: NewBookmark,
39
+ output: Bookmark,
40
+ errors: { Duplicate: z.object({ title: z.string() }) },
41
+ invalidates: () => [bookmarksTag()],
42
+ })
43
+
44
+ export const toggleRead = mutation({
45
+ input: BookmarkKey,
46
+ output: Bookmark,
47
+ errors: { NotFound: BookmarkKey },
48
+ invalidates: () => [bookmarksTag()],
49
+ })
50
+
51
+ const Visible = z.object({ items: Bookmarks, show: Show })
52
+
53
+ export const visible = fn({
54
+ input: Visible,
55
+ output: Bookmarks,
56
+ impl: ({ items, show }) => items.filter((b) => show === 'all' || !b.read),
57
+ })
58
+
59
+ export const isEmpty = fn({
60
+ input: Visible,
61
+ output: z.boolean(),
62
+ impl: ({ items, show }) => !items.some((b) => show === 'all' || !b.read),
63
+ })
64
+
65
+ export const DUPLICATE = 'This bookmark already exists'
66
+
67
+ export const bookmarksMachine = machine({
68
+ context: z.object({
69
+ draft: z.string(),
70
+ kind: Kind,
71
+ target: z.string(),
72
+ error: z.string().nullable(),
73
+ fields: z.object({ title: z.string().nullable(), kind: z.string().nullable() }),
74
+ }),
75
+ initialContext: {
76
+ draft: '',
77
+ kind: 'article',
78
+ target: '',
79
+ error: null,
80
+ fields: { title: null, kind: null },
81
+ },
82
+ initial: 'idle',
83
+ states: ({ ctx }) => ({
84
+ idle: {
85
+ on: [
86
+ on(Draft, { target: 'idle', assign: (e) => [op.set(ctx.draft, e.text)] }),
87
+ on(Add, {
88
+ target: 'adding',
89
+ assign: (e) => [
90
+ op.set(ctx.draft, e.title),
91
+ op.set(ctx.kind, e.kind),
92
+ op.set(ctx.error, null),
93
+ op.set(ctx.fields, { title: null, kind: null }),
94
+ ],
95
+ }),
96
+ on(ToggleRead, { target: 'toggling', assign: (e) => [op.set(ctx.target, e.id)] }),
97
+ ],
98
+ },
99
+ adding: {
100
+ ignore: [Draft, Add, ToggleRead],
101
+ invoke: invoke(addBookmark, {
102
+ input: { title: ctx.draft, kind: ctx.kind },
103
+ done: [
104
+ {
105
+ target: 'idle',
106
+ assign: () => [op.set(ctx.draft, '')],
107
+ navigate: (b) => ui.link(bookmarkPage, { id: b.id }),
108
+ },
109
+ ],
110
+ failed: {
111
+ Duplicate: [{ target: 'idle', assign: () => [op.set(ctx.error, DUPLICATE)] }],
112
+ Invalid: [{ target: 'idle', assign: (e) => [op.set(ctx.fields, e.fields)] }],
113
+ Unexpected: [{ target: 'idle', assign: (e) => [op.set(ctx.error, e.message)] }],
114
+ },
115
+ }),
116
+ },
117
+ toggling: {
118
+ ignore: [Draft, Add, ToggleRead],
119
+ invoke: invoke(toggleRead, {
120
+ input: { id: ctx.target },
121
+ done: [{ target: 'idle' }],
122
+ failed: {
123
+ NotFound: [{ target: 'idle' }],
124
+ Unexpected: [{ target: 'idle', assign: (e) => [op.set(ctx.error, e.message)] }],
125
+ },
126
+ }),
127
+ },
128
+ }),
129
+ })
@@ -0,0 +1,248 @@
1
+ import { contract, feature, op, ui } from '@hozu/core'
2
+ import { bookmarkPage, home } from '../../routes.ts'
3
+ import {
4
+ Add,
5
+ addBookmark,
6
+ bookmarksMachine,
7
+ bookmarksTag,
8
+ Draft,
9
+ DUPLICATE,
10
+ getBookmark,
11
+ isEmpty,
12
+ listBookmarks,
13
+ ToggleRead,
14
+ toggleRead,
15
+ visible,
16
+ } from './model.ts'
17
+
18
+ const kinds = ['article', 'video', 'podcast'] as const
19
+ const shows = [
20
+ { value: 'all', label: 'All' },
21
+ { value: 'unread', label: 'Unread' },
22
+ ] as const
23
+
24
+ export const Board = ui.view({
25
+ machine: bookmarksMachine,
26
+ route: home,
27
+ render: ({ ctx, search, when }) =>
28
+ ui.main({ class: 'mx-auto max-w-xl space-y-6 px-4 py-12' }, [
29
+ ui.h1({ class: 'text-3xl font-bold' }, ['Bookmarks']),
30
+ ui.form(
31
+ {
32
+ class: 'flex gap-2',
33
+ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) },
34
+ },
35
+ [
36
+ ui.label({ for: 'title', class: 'sr-only' }, ['Title']),
37
+ ui.input({
38
+ id: 'title',
39
+ name: 'title',
40
+ required: true,
41
+ minlength: 2,
42
+ maxlength: 80,
43
+ value: ctx.draft,
44
+ 'aria-invalid': op.neq(ctx.fields.title, null),
45
+ 'aria-describedby': 'title-error',
46
+ class: 'flex-1 rounded border px-3 py-2',
47
+ on: { input: ui.send(Draft, { text: ui.dom.value }) },
48
+ }),
49
+ ui.select(
50
+ { name: 'kind', 'aria-label': 'Kind', class: 'rounded border px-2' },
51
+ kinds.map((k) => ui.option({ value: k, selected: op.eq(ctx.kind, k) }, [k])),
52
+ ),
53
+ ui.button({ type: 'submit', class: 'rounded bg-indigo-600 px-4 py-2 text-white' }, ['Add']),
54
+ ],
55
+ ),
56
+ ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title]),
57
+ ui.if(op.neq(ctx.error, null), [ui.p({ role: 'alert', class: 'text-rose-600' }, [ctx.error])], []),
58
+ when(
59
+ ['adding'],
60
+ [
61
+ ui.p({ class: 'rounded border px-4 py-3 opacity-50', 'aria-busy': 'true' }, [
62
+ 'Adding ',
63
+ ctx.draft,
64
+ '…',
65
+ ]),
66
+ ],
67
+ ),
68
+ ui.nav(
69
+ { class: 'flex gap-2', 'aria-label': 'Show' },
70
+ shows.map((s) =>
71
+ ui.a(
72
+ {
73
+ href: ui.link(home, null, { show: s.value }),
74
+ 'aria-current': op.eq(search.show, s.value),
75
+ class:
76
+ 'rounded-full border px-3 py-1 aria-[current=true]:bg-indigo-600 aria-[current=true]:text-white',
77
+ },
78
+ [s.label],
79
+ ),
80
+ ),
81
+ ),
82
+ ui.query(
83
+ listBookmarks,
84
+ {},
85
+ {
86
+ ready: (items) =>
87
+ ui.if(
88
+ isEmpty({ items, show: search.show }),
89
+ [ui.p({ class: 'text-slate-500' }, ['No bookmarks'])],
90
+ [
91
+ ui.ul({ class: 'divide-y rounded border' }, [
92
+ ui.each(visible({ items, show: search.show }), 'id', (b) =>
93
+ ui.li({ class: 'flex items-center gap-3 px-4 py-3' }, [
94
+ ui.a({ href: ui.link(bookmarkPage, { id: b.id }), class: 'flex-1 underline' }, [
95
+ b.title,
96
+ ]),
97
+ ui.span({ class: 'text-xs text-slate-500' }, [b.kind]),
98
+ ui.button(
99
+ {
100
+ type: 'button',
101
+ class: 'text-sm',
102
+ on: { click: ui.send(ToggleRead, { id: b.id }) },
103
+ },
104
+ [ui.if(op.eq(b.read, true), ['Mark unread'], ['Mark read'])],
105
+ ),
106
+ ]),
107
+ ),
108
+ ]),
109
+ ],
110
+ ),
111
+ pending: ui.p({}, ['Loading…']),
112
+ failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Bookmarks are unavailable']) },
113
+ },
114
+ ),
115
+ ]),
116
+ })
117
+
118
+ export const Detail = ui.view({
119
+ route: bookmarkPage,
120
+ render: ({ params }) =>
121
+ ui.main({ class: 'mx-auto max-w-xl space-y-4 px-4 py-12' }, [
122
+ ui.query(
123
+ getBookmark,
124
+ { id: params.id },
125
+ {
126
+ ready: (b) =>
127
+ ui.article({}, [
128
+ ui.h1({ class: 'text-3xl font-bold' }, [b.title]),
129
+ ui.p({}, ['Kind: ', b.kind]),
130
+ ui.p({}, [ui.if(op.eq(b.read, true), ['Read'], ['Unread'])]),
131
+ ]),
132
+ pending: null,
133
+ failed: {
134
+ NotFound: () => ui.p({ role: 'alert' }, ['Bookmark not found']),
135
+ Unexpected: () => ui.p({ role: 'alert' }, ['Bookmark unavailable']),
136
+ },
137
+ },
138
+ ),
139
+ ui.a({ href: ui.link(home, null, null), class: 'underline' }, ['Back']),
140
+ ]),
141
+ })
142
+
143
+ export const typesDraft = contract(bookmarksMachine, {
144
+ given: { state: 'idle' },
145
+ when: [{ send: Draft, payload: { text: 'Hozu' } }],
146
+ expect: { state: 'idle', changes: { draft: 'Hozu' } },
147
+ })
148
+
149
+ export const addsBookmark = contract(bookmarksMachine, {
150
+ given: { state: 'idle' },
151
+ when: [
152
+ { send: Add, payload: { title: 'Hozu talk', kind: 'podcast' } },
153
+ { send: Draft, payload: { text: 'ignored while adding' } },
154
+ { done: addBookmark, result: { id: 'b3', title: 'Hozu talk', kind: 'podcast', read: false } },
155
+ ],
156
+ expect: {
157
+ state: 'idle',
158
+ changes: { kind: 'podcast' },
159
+ effects: [
160
+ { effect: addBookmark, input: { title: 'Hozu talk', kind: 'podcast' } },
161
+ { navigate: '/bookmarks/b3' },
162
+ ],
163
+ },
164
+ })
165
+
166
+ export const rejectsDuplicate = contract(bookmarksMachine, {
167
+ given: { state: 'adding' },
168
+ when: [{ failed: addBookmark, error: 'Duplicate', data: { title: 'Hozu talk' } }],
169
+ expect: { state: 'idle', changes: { error: DUPLICATE } },
170
+ })
171
+
172
+ export const rejectsInvalidTitle = contract(bookmarksMachine, {
173
+ given: { state: 'adding' },
174
+ when: [
175
+ {
176
+ failed: addBookmark,
177
+ error: 'Invalid',
178
+ data: {
179
+ message: 'title: Use at least 2 characters',
180
+ fields: { title: 'Use at least 2 characters', kind: null },
181
+ },
182
+ },
183
+ ],
184
+ expect: { state: 'idle', changes: { fields: { title: 'Use at least 2 characters' } } },
185
+ })
186
+
187
+ export const addFails = contract(bookmarksMachine, {
188
+ given: { state: 'adding' },
189
+ when: [{ failed: addBookmark, error: 'Unexpected', data: { message: 'offline' } }],
190
+ expect: { state: 'idle', changes: { error: 'offline' } },
191
+ })
192
+
193
+ export const togglesRead = contract(bookmarksMachine, {
194
+ given: { state: 'idle' },
195
+ when: [
196
+ { send: ToggleRead, payload: { id: 'b1' } },
197
+ { done: toggleRead, result: { id: 'b1', title: 'A', kind: 'article', read: true } },
198
+ ],
199
+ expect: {
200
+ state: 'idle',
201
+ changes: { target: 'b1' },
202
+ effects: [{ effect: toggleRead, input: { id: 'b1' } }],
203
+ },
204
+ })
205
+
206
+ export const toggleMissing = contract(bookmarksMachine, {
207
+ given: { state: 'toggling' },
208
+ when: [{ failed: toggleRead, error: 'NotFound', data: { id: 'b9' } }],
209
+ expect: { state: 'idle' },
210
+ })
211
+
212
+ export const toggleFails = contract(bookmarksMachine, {
213
+ given: { state: 'toggling' },
214
+ when: [{ failed: toggleRead, error: 'Unexpected', data: { message: 'offline' } }],
215
+ expect: { state: 'idle', changes: { error: 'offline' } },
216
+ })
217
+
218
+ export const bookmarks = feature({
219
+ id: 'bookmarks',
220
+ intent: {
221
+ summary:
222
+ 'A shared reading list: add bookmarks with a kind, mark them read, filter unread, one page each.',
223
+ invariants: ['Titles are unique, case-insensitive', 'New bookmarks are listed first'],
224
+ },
225
+ declarations: {
226
+ bookmarksTag,
227
+ Draft,
228
+ Add,
229
+ ToggleRead,
230
+ listBookmarks,
231
+ getBookmark,
232
+ addBookmark,
233
+ toggleRead,
234
+ visible,
235
+ isEmpty,
236
+ bookmarksMachine,
237
+ Board,
238
+ Detail,
239
+ typesDraft,
240
+ addsBookmark,
241
+ rejectsDuplicate,
242
+ rejectsInvalidTitle,
243
+ addFails,
244
+ togglesRead,
245
+ toggleMissing,
246
+ toggleFails,
247
+ },
248
+ })
@@ -0,0 +1,28 @@
1
+ import { project, ui } from '@hozu/core'
2
+ import { zodAdapter } from '@hozu/schema-zod'
3
+ import { getBookmark, listBookmarks } from './features/bookmarks/model.ts'
4
+ import { Board, bookmarks, Detail } from './features/bookmarks/views.ts'
5
+ import { bookmarkPage, home } from './routes.ts'
6
+
7
+ export default project({
8
+ schema: zodAdapter,
9
+ styles: new URL('./app.css', import.meta.url),
10
+ site: { url: 'http://localhost:3000', name: 'Bookmarks', lang: 'en' },
11
+ routes: { home, bookmarkPage },
12
+ pages: [
13
+ ui.page(home, {
14
+ views: [Board],
15
+ head: { render: () => ({ title: 'Bookmarks', description: 'A shared reading list.' }) },
16
+ }),
17
+ ui.page(bookmarkPage, {
18
+ views: [Detail],
19
+ head: {
20
+ query: getBookmark,
21
+ input: (params) => ({ id: params.id }),
22
+ render: (b) => ({ title: b.title, description: b.title, type: 'article' }),
23
+ },
24
+ entries: { query: listBookmarks, input: {}, params: (b) => ({ id: b.id }) },
25
+ }),
26
+ ],
27
+ features: [bookmarks],
28
+ })
@@ -0,0 +1,11 @@
1
+ import { route } from '@hozu/core'
2
+ import { z } from 'zod'
3
+
4
+ export const Show = z.enum(['all', 'unread'])
5
+
6
+ export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
7
+ export const bookmarkPage = route({
8
+ path: '/bookmarks/:id',
9
+ params: z.object({ id: z.string() }),
10
+ search: null,
11
+ })
@@ -0,0 +1,14 @@
1
+ import { createServer } from '@hozu/adapter-node'
2
+ import { buildProject } from '@hozu/core/ir'
3
+ import { compileStyles } from '@hozu/css'
4
+ import project from './hozu.config.ts'
5
+ import { createResolvers } from './server.ts'
6
+
7
+ const port = Number(process.env.PORT ?? 3000)
8
+ const build = buildProject(project, { sources: false })
9
+
10
+ createServer({
11
+ build,
12
+ styles: await compileStyles(build),
13
+ resolvers: createResolvers(),
14
+ }).listen(port, () => console.log(`Bookmarks on http://localhost:${port}`))
@@ -0,0 +1,34 @@
1
+ import { resolvers } from '@hozu/data'
2
+ import { addBookmark, getBookmark, listBookmarks, toggleRead } from './features/bookmarks/model.ts'
3
+ import project from './hozu.config.ts'
4
+
5
+ type Kind = 'article' | 'video' | 'podcast'
6
+
7
+ export function createResolvers() {
8
+ const items = [
9
+ { id: 'b1', title: 'Closed-world UI', kind: 'article' as Kind, read: false },
10
+ { id: 'b2', title: 'Islands explained', kind: 'video' as Kind, read: true },
11
+ ]
12
+ let seq = items.length
13
+ return resolvers(project, (implement) => [
14
+ implement(listBookmarks, () => items.map((b) => ({ ...b }))),
15
+ implement(getBookmark, ({ id }, { fail }) => {
16
+ const b = items.find((x) => x.id === id)
17
+ return b ? { ...b } : fail('NotFound', { id })
18
+ }),
19
+ implement(addBookmark, ({ title, kind }, { fail }) => {
20
+ const clean = title.trim()
21
+ if (items.some((b) => b.title.toLowerCase() === clean.toLowerCase()))
22
+ return fail('Duplicate', { title: clean })
23
+ const b = { id: `b${++seq}`, title: clean, kind, read: false }
24
+ items.unshift(b)
25
+ return { ...b }
26
+ }),
27
+ implement(toggleRead, ({ id }, { fail }) => {
28
+ const b = items.find((x) => x.id === id)
29
+ if (!b) return fail('NotFound', { id })
30
+ b.read = !b.read
31
+ return { ...b }
32
+ }),
33
+ ])
34
+ }
@@ -0,0 +1,56 @@
1
+ # Hozu patterns
2
+
3
+ Patterns marked *(example)* are used in `example/`, next to this file.
4
+
5
+ - **Form with a server-side error** *(example)*:
6
+ - `ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [label, input, button])`.
7
+ - The machine goes to `adding`, which invokes the mutation. `failed.Duplicate` sets `ctx.error`.
8
+ - Show the error with `ui.if(op.neq(ctx.error, null), [ui.p({ role: 'alert' }, [ctx.error])], [])`.
9
+ - To clear the input after success, bind `value: ctx.draft` and reset `draft` in `done`.
10
+ - **Busy states (a mutation in flight)** *(example)*: render every control **once**. In each busy state, `ignore` the events
11
+ those controls send. Do not duplicate controls under `when`. Handling them there would re-enter the busy state
12
+ instead, and HZ005 would reject leaving them unhandled.
13
+ - **Filtering and empty state** *(example)*: `ui.each(visible({ items, show: ctx.show }), 'id', …)` and
14
+ `ui.if(isEmpty({ items, show: ctx.show }), [ui.p({}, ['No items'])], [ui.ul(...)])`, both using `fn`s.
15
+ - **Toggle buttons** (`aria-pressed`): `'aria-pressed': op.eq(ctx.show, s.value)` plus
16
+ `on: { click: ui.send(SetShow, { show: s.value }) }` for each option of a constant list.
17
+ - **Per-item action** *(example)*:
18
+ - `ui.send(ToggleRead, { id: item.id })` → a `toggling` state that stores `ctx.target` and invokes the mutation
19
+ with `{ id: ctx.target }`.
20
+ - Label text by data: `ui.if(op.eq(item.read, true), ['Mark unread'], ['Mark read'])`.
21
+ - **Select bound to an enum**:
22
+ `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])))`,
23
+ where the event payload is `{ kind: Kind }`, the zod enum.
24
+ - **Detail page with a 404** *(example)*: a view with `route: itemPage` and no machine,
25
+ `ui.query(getItem, { id: params.id }, { ready, pending: null, failed: { NotFound: () => ..., Unexpected: () => ... } })`,
26
+ plus `head.query: getItem`.
27
+ - **Refresh after a mutation** *(example)*: tag the query, and list the tag in the mutation's `invalidates`. A mutation can
28
+ read only its input for tag params; use a list-wide tag when it affects many items.
29
+
30
+ - **Filter in the URL** *(example)* (shareable, works without JS): declare `search` on the route, render the options as
31
+ `ui.link(home, null, { show: s.value })` links with `'aria-current': op.eq(search.show, s.value)`, and filter with
32
+ `fn`s over `search.show`. Only use machine context for filters that should not survive a reload.
33
+ - **Go to what was just created** *(example)*: `done: [{ target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }]`.
34
+ - **No-JS form** *(example)*: every value the submit needs is a named field read with `ui.dom.form('name')`; the server runs the
35
+ machine for a native post. Per-item actions without JS: wrap the button in its own small form.
36
+ - **UI that survives following a link** (a cart, a player, a chat box): list the same
37
+ view with a machine on every page that should keep it, in the same order, e.g. `views: [ProductGrid, CartPanel]`
38
+ and `views: [ProductDetail, CartPanel]`. Links between those pages then swap only the other views; the kept view's
39
+ DOM and machine state stay. Nothing to declare: a view is kept only if it never reads `params`/`search` (neither
40
+ in its tree nor in its machine). `hozu plan <route>` lists what is kept per target route. Style the loading
41
+ state with `html[data-hozu-navigating]`.
42
+ - **Two languages**: `site.locales`, one `ui.messages` per feature, a language switcher of
43
+ `ui.a({ href: ui.alternate('en'), hreflang: 'en', lang: 'en' }, ['English'])` links, and `ui.format.date` for dates.
44
+ - **Load more / infinite scroll**: context `{ cursors: [null], last: null }`;
45
+ `ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => ... }))`; in the last page
46
+ (`op.and(op.eq(cursor, ctx.last), op.neq(page.next, null))`) render a button with `on: { click: ui.send(More,
47
+ { cursor: page.next }) }` and a sentinel `ui.div({ class: 'h-px', on: { visible: ui.send(More, …) } }, [])`.
48
+ `More` appends the cursor and sets `last`, guarded by `op.and(op.neq(ctx.last, e.cursor), op.neq(e.cursor, null))`
49
+ so a page loads once. It needs JS; a list that must work without JS pages through `search` links.
50
+ - **Optimistic item** *(example)*: while the mutation runs, render the pending value from context
51
+ in the busy state: `when(['adding'], [ui.p({ class: 'opacity-50', 'aria-busy': 'true' }, ['Adding ', ctx.draft, '…'])])`.
52
+ Leaving the state (done or failed) removes it; the refreshed query shows the real item.
53
+ - **Field errors** *(example)*: context `fields: z.object({ title: z.string().nullable(), kind:
54
+ z.string().nullable() })`, reset it on submit, `failed.Invalid: [{ target: 'idle', assign: (e) => [op.set(ctx.fields,
55
+ e.fields)] }]`, and render `ui.p({ id: 'title-error' }, [ctx.fields.title])` with `'aria-invalid': op.neq(ctx.fields.title,
56
+ null)` on the input. It also works without JS.
@@ -0,0 +1,139 @@
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 (`ui.widget` / `ui.use`) for
25
+ third-party DOM libraries.
26
+
27
+ ## Forms without JavaScript
28
+ A submit whose payload reads only `ui.dom.form('name')`, literals, context, params and search also works without JS
29
+ (otherwise HZ036 warns). The server runs the same machine and mutation, then redirects (on `navigate`, or when the
30
+ machine is back where it started) or re-renders the page with the result (for example an error alert). Put every
31
+ value the submit needs in named fields: a `<select name="kind">`, not a separate change event.
32
+
33
+ ## Field errors (`Invalid`)
34
+ Every mutation also has the framework error `Invalid` = `{ message, fields }`: one key per top-level input field
35
+ (`string | null`). It is returned when the input fails its schema (put limits there:
36
+ `z.string().min(2, 'Use at least 2 characters')`), and a resolver can return it:
37
+ `fail('Invalid', { message, fields: { title: 'Already taken' } })`.
38
+ - `failed.Invalid` is optional (without it, `Unexpected` handles it).
39
+ - With it: `assign: (e) => [op.set(ctx.fields, e.fields)]`, and show `ctx.fields.title` under the input with
40
+ `'aria-invalid': op.neq(ctx.fields.title, null)`.
41
+ - Never declare errors named `Invalid` or `Unexpected` yourself (HZ014).
42
+
43
+ ## Pages
44
+ `ui.page(route, { views, head, entries?, assert? })`.
45
+ - `head.render` returns `{ title, description?, type?: 'website' | 'article', image?, published?, noindex? }`.
46
+ `head.query` + `head.input: (params, locale) => …` load data for it; a failing head query sets the HTTP status
47
+ (NotFound → 404). `head.redirects` maps declared errors to routes.
48
+ - `entries: { query, input, params: (item) => … }` lists the pages of a route with params for the sitemap.
49
+ - `project({ notFound: route, error: route })` renders those pages for 404 / 500.
50
+ - A view listed with a machine on several pages, in the same order, stays mounted when links move between them
51
+ (see `patterns.md`).
52
+
53
+ ## Sessions
54
+ - `project({ session: z.object({ user: z.string() }) })` declares the identity. Queries with `scope: 'user'` and
55
+ mutations receive `session`; public resolvers never do.
56
+ - `createServer({ session: (request) => value })`, or `sessionCookie({ name, secret })` from
57
+ `@hozu/runtime-server` for a signed cookie. Mutations can call `setSession(value)`.
58
+
59
+ ## Languages (i18n)
60
+ - `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`). Routes and
61
+ `ui.link` stay locale-free; links keep the current locale. `/` and locale-less URLs redirect by
62
+ `Accept-Language`. `<html lang>`, hreflang, og:locale and the sitemap are derived.
63
+ - Text: `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`,
64
+ added to the feature's `declarations`. Use `text.title` or `text.saved({ count })` in views and `head.render`.
65
+ Every locale needs every key with the same `{placeholders}` (HZ040). Plurals:
66
+ `'{n, plural, =0 {none} one {# item} other {# items}}'`; `select` also works.
67
+ - Machines never hold translated text (HZ041): store a code (`'duplicate'`) and pick the message in the view.
68
+ - `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
69
+ `ui.format.relative(n, 'day')`, `ui.format.list(xs)`.
70
+ - `locale` is in every view scope and the second argument of `head.input`. `ui.alternate('zh-TW')` is the current
71
+ page in another locale.
72
+
73
+ ## Environment
74
+ `project({ env: { server: z.object({ DB_URL: z.string() }), public: z.object({ SUPPORT_EMAIL: z.string().email() }) } })`.
75
+ Both are parsed when the server starts (defaults and `z.coerce` apply; a missing value stops startup). Resolvers get
76
+ `ctx.env` (server values). Views read public values with `ui.env(PublicEnv).SUPPORT_EMAIL`. Machines cannot read env
77
+ (HZ041).
78
+
79
+ ## HTTP
80
+ Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
81
+ ```ts
82
+ http: {
83
+ basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
84
+ trailingSlash: 'always', // or 'never'; the other form answers 308
85
+ redirects: { // keyed by the old path; never a path a page owns (HZ037)
86
+ '/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true }, // 308
87
+ '/docs': { to: 'https://docs.example.com', permanent: false }, // 307
88
+ },
89
+ headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // or routes: [post]; not cache-control (HZ038)
90
+ },
91
+ ```
92
+ There are no rewrites: one URL has one owner.
93
+
94
+ ## Server options
95
+ `createServer({ build, styles, resolvers, session?, onError?, csp?, images?, og?, preview? })` from
96
+ `@hozu/adapter-node`.
97
+ - `onError(error, { effect | path })` receives every unexpected failure.
98
+ - A strict CSP, `nosniff` and a cross-site POST check are on by default (`csp` adds sources, e.g.
99
+ `{ script: ['https://analytics.example'] }`, or `false`).
100
+ - Test a mutation with curl:
101
+ `curl -X POST localhost:4700/_hozu/effect -H 'content-type: application/json' -d '{"effect":"items.addItem","input":{"title":"x"},"keys":[]}'`.
102
+
103
+ ## Content, images, share images, fonts
104
+ - **Markdown:** `@hozu/content` turns `content/posts/*.md` (YAML front matter checked by a schema) into
105
+ `{ slug, data, html, headings }`: `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`
106
+ in `server.ts`, returned from ordinary query resolvers; render the body with `ui.html(post.html)`.
107
+ - **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
108
+ dimensions). With `@hozu/image` installed, pass `images: await optimizeImages(build)` to `createServer` (and
109
+ `hozu build` does it itself): raster assets get WebP `srcset` widths and `sizes`.
110
+ - **Share images:** `head.render` → `image: ui.og({ title, subtitle })` renders a 1200×630 card; pass
111
+ `og: ogImage` (from `@hozu/image`) to `createServer`.
112
+ - **Fonts:** a local `@font-face` gets a size-matched `"<Family> Fallback"` automatically.
113
+
114
+ ## Preview (drafts)
115
+ `createServer({ preview: { secret } })`; `GET /_hozu/preview?secret=…&path=/posts/a` turns preview on (a signed
116
+ cookie), `/_hozu/preview/exit` turns it off. Resolvers get `ctx.preview`; preview responses are never cached and are
117
+ noindex.
118
+
119
+ ## PWA and offline
120
+ A web app manifest is derived from `site` (`name`, `themeColor`, `icon`). `site.offline: route` is a static page
121
+ shown when the network is down; a service worker is generated (HZ043: no params, no per-request data).
122
+
123
+ ## Testing rendered pages
124
+ `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` gives
125
+ `{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form.
126
+
127
+ ## Deployment
128
+ `hozu build` writes `dist/public/` (static files for any host or CDN) and `dist/manifest.json`. On Node:
129
+ `createServer({ build: buildProject(project, { manifest }), manifest, publicDir: 'dist/public', … })`. On Bun, Deno,
130
+ Cloudflare Workers or Vercel the server is `createHandler({ build, manifest, resolvers, render })` from
131
+ `@hozu/runtime-server` with `export default { fetch: handler.fetch }`, where
132
+ `import * as render from './dist/server/render.js'` is the page code `hozu build` generates (edge runtimes cannot
133
+ generate it at startup). Page cache and tag revalidation are per instance.
134
+ ```ts
135
+ import manifest from './dist/manifest.json' with { type: 'json' }
136
+ import * as render from './dist/server/render.js'
137
+ const handler = createHandler({ build: buildProject(project, { manifest }), manifest, render, resolvers: createResolvers() })
138
+ export default { fetch: handler.fetch }
139
+ ```
@@ -0,0 +1 @@
1
+ @import "tailwindcss";