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.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/bin/create-hozu.js +4 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +64 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/skill/SKILL.md +202 -0
- package/skill/changing.md +40 -0
- package/skill/diagnostics.md +39 -0
- package/skill/example/app.css +1 -0
- package/skill/example/features/bookmarks/model.ts +129 -0
- package/skill/example/features/bookmarks/views.ts +248 -0
- package/skill/example/hozu.config.ts +28 -0
- package/skill/example/routes.ts +11 -0
- package/skill/example/serve.ts +14 -0
- package/skill/example/server.ts +34 -0
- package/skill/patterns.md +56 -0
- package/skill/reference.md +139 -0
- package/templates/app/app.css +1 -0
- package/templates/app/features/site/views.ts +15 -0
- package/templates/app/gitignore +2 -0
- package/templates/app/hozu.config.ts +13 -0
- package/templates/app/routes.ts +3 -0
- package/templates/app/serve.ts +14 -0
- package/templates/app/server.ts +6 -0
- package/templates/app/tsconfig.json +20 -0
- package/templates/guide.md +20 -0
|
@@ -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";
|