create-hozu 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +68 -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} +7 -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 +12 -0
- package/skill/topics/views.md +34 -0
- package/skill/topics/widgets.md +25 -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 closures)
|
|
19
|
+
input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
20
|
+
impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
- Call a `fn` from views or machines with data: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
|
|
24
|
+
- Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
|
|
25
|
+
`scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
|
|
26
|
+
- **Resolvers** (`server.ts`, or `features/<name>/server.ts` from the scaffold):
|
|
27
|
+
```ts
|
|
28
|
+
export const createResolvers = () => resolvers(project, (implement) => [
|
|
29
|
+
implement(listItems, () => items.map((i) => ({ ...i }))),
|
|
30
|
+
implement(getItem, ({ id }, { fail }) => items.find((i) => i.id === id) ?? fail('NotFound', { id })),
|
|
31
|
+
implement(addItem, ({ title }, { fail, session }) => /* … */ ),
|
|
32
|
+
])
|
|
33
|
+
```
|
|
34
|
+
- Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
|
|
35
|
+
`fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
- **Node:** `npm start` (`node --import @hozu/transform/register serve.ts`). `hozu build` writes `dist/public/`,
|
|
4
|
+
`dist/manifest.json` and `dist/server/render.js`; serve with
|
|
5
|
+
`createServer({ build: buildProject(project, { manifest }), manifest, publicDir: 'dist/public', … })`.
|
|
6
|
+
- **Edge (Bun, Deno, Workers, Vercel):** bundle with `hozuTransform()` from `@hozu/transform/esbuild`, then
|
|
7
|
+
`createHandler({ build: buildProject(project, { manifest }), manifest, render, resolvers })` from
|
|
8
|
+
`@hozu/runtime-server`, `export default { fetch: handler.fetch }`, where `render` is `import * as render from
|
|
9
|
+
'./dist/server/render.js'`.
|
|
10
|
+
- **Static host (GitHub Pages):** `exportStatic({ build, styles, resolvers, outDir })` from `@hozu/adapter-static`
|
|
11
|
+
writes every page without per-request data plus the files they link to, and lists skipped routes.
|
|
12
|
+
- Set `SESSION_SECRET` (and `SESSION_SECURE=true`) when the app has sessions.
|
|
@@ -9,14 +9,14 @@ around the rule.
|
|
|
9
9
|
| HZ002 | event handled nowhere | handle it in a state or remove it |
|
|
10
10
|
| HZ003 / HZ007 | unknown effect / reference | add it to `feature({ declarations })`, or fix the name (the patch suggests one) |
|
|
11
11
|
| HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
|
|
12
|
-
| HZ005 | a node sends an event in a state that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
|
|
12
|
+
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
|
|
13
13
|
| HZ006 | crossing a feature boundary | import the feature and use its `exports` |
|
|
14
14
|
| HZ008 | a path does not exist in the schema | fix the property name |
|
|
15
15
|
| HZ009 | a guardless transition shadows later ones | put guarded transitions first |
|
|
16
|
-
| HZ014 | wrong builder output | follow the builder signature |
|
|
16
|
+
| HZ014 | wrong builder output, or a method called on data (`.map`, `.toUpperCase()`) | follow the builder signature; lists: `ui.each`; computation: a `fn()` |
|
|
17
17
|
| HZ015 / HZ017 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
18
|
-
| HZ016 | a transition
|
|
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,7 @@ around the rule.
|
|
|
36
36
|
| HZ040 | a locale lacks a message, or uses other `{placeholders}` | add/translate the key in that locale |
|
|
37
37
|
| HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
|
|
38
38
|
| HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
|
|
39
|
+
| HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
|
|
40
|
+
| HZ045 | `serve.ts` misses the widget bundle or the session store | add `widgets: await bundleWidgets(build)` / `session: sessionCookie(…)` |
|
|
41
|
+
| HZ046 | an endpoint path is reserved, has params, or collides with a page, redirect or endpoint | use a static path such as `/api/…` (patch) |
|
|
39
42
|
| HZ042 | `site.locales` empty / missing `site.lang` / not a canonical tag, or `ui.alternate` of an undeclared locale | fix the list (`'zh-TW'`, not `'zh_tw'`) |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Endpoints (webhooks, JSON APIs, auth callbacks)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const orderHook = endpoint({ method: 'POST', path: '/api/hooks/order',
|
|
5
|
+
input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) // in declarations
|
|
6
|
+
implement(orderHook, ({ id }, { request, session, setSession, env }) => ({ received: id })) // in resolvers
|
|
7
|
+
```
|
|
8
|
+
- GET input comes from the query string, POST input from a JSON or form body; invalid input answers 400
|
|
9
|
+
`{ message, fields }`. The output is validated and sent as JSON.
|
|
10
|
+
- `output: 'response'`: return a web `Response` yourself (redirects, headers); `setSession(value)` adds the cookie.
|
|
11
|
+
- Paths are static and outside pages, redirects and `/_hozu/` (HZ046, with a patch). Cross-site browser POSTs are
|
|
12
|
+
rejected; server-to-server calls (no `Origin`) are accepted. `hozu get /api/x` tries one without a server.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
|
|
3
|
+
`project({ env: { server: z.object({ DB_URL: z.string() }), public: z.object({ SUPPORT_EMAIL: z.string().email() }) } })`.
|
|
4
|
+
Both are parsed at startup (defaults and `z.coerce` apply; a missing value stops startup). Resolvers read
|
|
5
|
+
`ctx.env`; views read public values with `ui.env(PublicEnv).SUPPORT_EMAIL`. Machines cannot read env (HZ041).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
- **Works without JavaScript** when the submit payload reads only `ui.dom.form('name')`, literals, context, params
|
|
4
|
+
and search (else HZ036 warns): the server runs the same machine for a native post, then redirects or re-renders
|
|
5
|
+
with the result. Put every value the submit needs in a named field (a `<select name="kind">`).
|
|
6
|
+
```ts
|
|
7
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
|
|
8
|
+
ui.label({ for: 'title' }, ['Title']),
|
|
9
|
+
ui.input({ id: 'title', name: 'title', required: true, minlength: 2, value: ctx.draft,
|
|
10
|
+
'aria-invalid': ctx.fields.title !== null, 'aria-describedby': 'title-error',
|
|
11
|
+
on: { input: ui.send(Draft, { text: ui.dom.value }) } }),
|
|
12
|
+
ui.select({ name: 'kind', 'aria-label': 'Kind' }, kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k]))),
|
|
13
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
14
|
+
])
|
|
15
|
+
ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
|
|
16
|
+
```
|
|
17
|
+
- **Field errors:** context `fields: z.object({ title: z.string().nullable() })`, reset on submit
|
|
18
|
+
(`ctx.fields = { title: null }`), and `failed.Invalid: { target: 'idle', assign: (e) => { ctx.fields = e.fields } }`.
|
|
19
|
+
Limits live in the mutation's input schema: `z.string().min(2, 'Use at least 2 characters')`.
|
|
20
|
+
- **Server error:** a declared error sets `ctx.error`; show `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`.
|
|
21
|
+
- **Clear after success:** bind `value: ctx.draft` and reset it in `done`.
|
|
22
|
+
- **Per-item actions without JS:** wrap each button in its own small form.
|
|
23
|
+
- **Enum from a select:** `ui.dom.form('kind')` or `ui.dom.value` fills an enum field only when every literal
|
|
24
|
+
option value is a member (HZ033).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# HTTP
|
|
2
|
+
|
|
3
|
+
Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
|
|
4
|
+
```ts
|
|
5
|
+
http: {
|
|
6
|
+
basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
|
|
7
|
+
trailingSlash: 'always', // or 'never'; the other form answers 308
|
|
8
|
+
redirects: { // keyed by the old path; never a path a page owns (HZ037)
|
|
9
|
+
'/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true },
|
|
10
|
+
'/docs': { to: 'https://docs.example.com', permanent: false },
|
|
11
|
+
},
|
|
12
|
+
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
|
|
13
|
+
},
|
|
14
|
+
```
|
|
15
|
+
Server options: `createServer({ build, styles, resolvers, session?, widgets?, onError?, csp?, images?, og?, preview? })`.
|
|
16
|
+
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
|
|
17
|
+
There are no rewrites: one URL has one owner. For your own HTTP routes, see `hozu docs endpoints`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Languages
|
|
2
|
+
|
|
3
|
+
- `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`); routes and
|
|
4
|
+
`ui.link` stay locale-free; `/` redirects by `Accept-Language`. `<html lang>`, hreflang and the sitemap are derived.
|
|
5
|
+
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`
|
|
6
|
+
in the feature's `declarations`; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
|
|
7
|
+
needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
8
|
+
- Machines never hold translated text (HZ041): store a code and choose the message in the view.
|
|
9
|
+
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
|
10
|
+
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view; `ui.alternate('zh-TW')` links the
|
|
11
|
+
current page in another language.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Machine (one per feature)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const m = machine({
|
|
5
|
+
context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
|
|
6
|
+
initialContext: { draft: '', error: null, target: '' },
|
|
7
|
+
initial: 'idle',
|
|
8
|
+
states: ({ ctx }) => ({
|
|
9
|
+
idle: {
|
|
10
|
+
on: [
|
|
11
|
+
on(Draft, { target: 'idle', assign: (e) => { ctx.draft = e.text } }),
|
|
12
|
+
on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
|
|
13
|
+
on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
|
|
14
|
+
on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
adding: { // runs addItem on entry; drops events it does not handle
|
|
18
|
+
invoke: invoke(addItem, {
|
|
19
|
+
input: { title: ctx.draft },
|
|
20
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
|
|
21
|
+
failed: { // every declared error + Unexpected (+ optional Invalid)
|
|
22
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
|
|
23
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
},
|
|
27
|
+
removing: { invoke: invoke(removeItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) },
|
|
28
|
+
flash: { after: [{ ms: 3000, target: 'idle' }], ignore: [Add] }, // timers; ignore only without invoke
|
|
29
|
+
}),
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
- **assign** writes context: `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(item)`,
|
|
33
|
+
`ctx.list = ctx.list.filter((i) => i.id !== e.id)`. Values are event (`e`), result (`r`) or error fields,
|
|
34
|
+
context, literals, operators and `fn()` calls.
|
|
35
|
+
- **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
36
|
+
- **navigate** sends the browser to `ui.link(route, params, search)` after the transition.
|
|
37
|
+
- `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
|
|
38
|
+
- A transition to the same state re-enters it and re-runs its `invoke`: do not handle the busy event in the busy
|
|
39
|
+
state. Machines never hold translated text (store a code, choose the message in the view).
|
|
40
|
+
- Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Routes and pages
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
// routes.ts
|
|
5
|
+
export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
|
|
6
|
+
export const itemPage = route({ path: '/items/:id', params: z.object({ id: z.string() }), search: null })
|
|
7
|
+
export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.array(z.string()).min(1) }), search: null })
|
|
8
|
+
```
|
|
9
|
+
- `:x` one segment, `:x?` optional (nullable), `:x+` / `:x*` one-or-more / zero-or-more (string[]) (HZ024).
|
|
10
|
+
- `search`: flat scalars or enums, each with a default or nullable (HZ035). URLs are canonical (keys sorted,
|
|
11
|
+
defaults left out). Changing `search` is a navigation: a filter in the URL is a plain `ui.link`, no machine.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// hozu.config.ts
|
|
15
|
+
export default project({
|
|
16
|
+
schema: zodAdapter, styles: new URL('./app.css', import.meta.url),
|
|
17
|
+
site: { url: 'https://example.com', name: 'Items', lang: 'en' },
|
|
18
|
+
routes: { home, itemPage }, notFound: missing, // notFound / error: routes rendered for 404 / 500
|
|
19
|
+
pages: [
|
|
20
|
+
ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } }),
|
|
21
|
+
ui.page(itemPage, {
|
|
22
|
+
views: [Detail],
|
|
23
|
+
head: {
|
|
24
|
+
query: getItem, // its failure sets the status (NotFound → 404)
|
|
25
|
+
input: (params) => ({ id: params.id }),
|
|
26
|
+
render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
|
|
27
|
+
},
|
|
28
|
+
entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
|
|
29
|
+
}),
|
|
30
|
+
],
|
|
31
|
+
features: [items],
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
- `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image` (a URL, `ui.asset(...)`
|
|
35
|
+
or `ui.og({ title })`), `published`, `noindex`. `head.redirects: { Unauthorized: login }` maps errors to routes.
|
|
36
|
+
- A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
|
|
37
|
+
failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
|
|
38
|
+
- A page loads JS only when a machine-bound part renders on it (`hozu plan <route>`). A view with a machine listed
|
|
39
|
+
on several pages, in the same order, keeps its DOM and state across links when it never reads `params` / `search`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Common UI patterns
|
|
2
|
+
|
|
3
|
+
Each pattern is complete here; there is no need to open other files.
|
|
4
|
+
|
|
5
|
+
- **Busy state:** render every control once; the state with `invoke` drops repeated submits. Progress:
|
|
6
|
+
`when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
|
|
7
|
+
- **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
|
|
8
|
+
and the refreshed query shows the real item.
|
|
9
|
+
- **Filter and empty state** (in context): two `fn`s over the list:
|
|
10
|
+
```ts
|
|
11
|
+
export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
|
|
12
|
+
impl: ({ items, show }) => items.filter((i) => show === 'all' || (show === 'done') === i.done) })
|
|
13
|
+
export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
|
|
14
|
+
impl: ({ items, show }) => !items.some((i) => show === 'all' || (show === 'done') === i.done) })
|
|
15
|
+
// view
|
|
16
|
+
isEmpty({ items, show: ctx.show })
|
|
17
|
+
? ui.p({ class: 'text-slate-500' }, ['No items'])
|
|
18
|
+
: ui.ul({}, [ui.each(visible({ items, show: ctx.show }), 'id', (i) => ui.li({}, [i.title]))])
|
|
19
|
+
```
|
|
20
|
+
- **Search as you type:** context `search: z.string()`; `ui.input({ type: 'search', 'aria-label': 'Search', value:
|
|
21
|
+
ctx.search, on: { input: ui.send(Search, { text: ui.dom.value }) } })`; `on(Search, { target: 'idle', assign: (e) =>
|
|
22
|
+
{ ctx.search = e.text } })`; filter with a `fn({ input: z.object({ items, text: z.string() }), … })`.
|
|
23
|
+
- **Toggle buttons:** for each option of a constant list,
|
|
24
|
+
`ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
|
|
25
|
+
- **Filter in the URL** (shareable, no JS): `search` on the route, options as
|
|
26
|
+
`ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
|
|
27
|
+
- **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
|
|
28
|
+
```ts
|
|
29
|
+
ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
|
|
30
|
+
ui.input({ type: 'hidden', name: 'id', value: item.id }),
|
|
31
|
+
ui.button({ type: 'submit' }, [item.done ? 'Reopen' : 'Done']),
|
|
32
|
+
])
|
|
33
|
+
// machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
|
|
34
|
+
// toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
|
|
35
|
+
```
|
|
36
|
+
Try it without a server: `hozu post / --field title=x --next 'POST / id=i1&@Done' --next /`.
|
|
37
|
+
- **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
|
|
38
|
+
- **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
|
|
39
|
+
- **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
|
|
40
|
+
- **Detail page with a 404:** `hozu docs pages`.
|
|
41
|
+
- **UI kept across links** (a cart, a player): list the same machine view on each page, in the same order.
|
|
42
|
+
- **Load more:** context `{ cursors: [null], last: null }`;
|
|
43
|
+
`ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
|
|
44
|
+
(`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
|
|
45
|
+
`More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
- **Without a server** (no need to start one): `hozu get /path --select 'button[aria-pressed=true]' --forms` and
|
|
4
|
+
`hozu post / --field title=A --next 'POST / @Delete' --next /`.
|
|
5
|
+
- `post` submits like a browser **without JavaScript** (a native form post), so it also checks no-JS behaviour.
|
|
6
|
+
- Each step prints its status, redirect and `set-cookie` attributes (`HttpOnly`, `SameSite`); the session cookie
|
|
7
|
+
is kept across `--next` steps. Two users: run two commands.
|
|
8
|
+
- Endpoints: `hozu get '/api/items?x=1'`.
|
|
9
|
+
- In code: `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` →
|
|
10
|
+
`{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form.
|
|
11
|
+
- Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
|
|
12
|
+
- Browser tests: wait for `html[data-hozu-ready]` (set after hydration) before clicking.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Views
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const Board = ui.view({
|
|
5
|
+
machine: m, // optional: without it, no ctx / when / events, and 0 JS
|
|
6
|
+
route: home, // optional: render gets { params, search } typed by the route
|
|
7
|
+
render: ({ ctx, when, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
|
|
8
|
+
})
|
|
9
|
+
```
|
|
10
|
+
- **Elements:** `ui.<tag>(attrs, children)` for every HTML and SVG element; void tags (`input`, `img`) take only attrs.
|
|
11
|
+
Children are nodes, strings, numbers, data values, and `null` / `false` (render nothing).
|
|
12
|
+
- **Attributes:** HTML names in lower case (`for`, `minlength`, `aria-pressed`, `data-x`), typed per tag. Values are
|
|
13
|
+
literals or data: `'aria-pressed': ctx.show === 'all'`, `title: ctx.error ?? 'OK'`.
|
|
14
|
+
- **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026). Conditional classes:
|
|
15
|
+
`toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. CSS variables: `vars: { '--hue': item.hue }`. No `style`.
|
|
16
|
+
- **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`,
|
|
17
|
+
`list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`. With an enter/leave animation:
|
|
18
|
+
`ui.if(cond, [then], [else], 'fade')`.
|
|
19
|
+
- **By machine state:** `when(['adding', 'saving'], [ui.p({}, ['Saving…'])])`.
|
|
20
|
+
- **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`; `ui.each(tags, null, (t) => …)` for primitives.
|
|
21
|
+
Never `.map` over data (only over constants: `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`).
|
|
22
|
+
- **Text:** template strings work: `` `${n} items` ``.
|
|
23
|
+
- **Events:** `on: { click: ui.send(Event, payload) }`, any DOM event name plus `visible` (entered the viewport).
|
|
24
|
+
Payload fields: literals, data, `ui.dom.value`, `ui.dom.form('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`,
|
|
25
|
+
`ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field only from a `<select>` or radios whose literal
|
|
26
|
+
option values are all members (HZ033).
|
|
27
|
+
- **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). The third
|
|
28
|
+
argument exists only when the route declares `search`: `ui.link(home, null, { show: 'done' })`.
|
|
29
|
+
- **Data:** `ui.query(listItems, input, { ready: (items) => …, pending: ui.p({}, ['Loading…']), failed: { NotFound:
|
|
30
|
+
() => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`.
|
|
31
|
+
Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
|
|
32
|
+
invalidates refresh in place.
|
|
33
|
+
- **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
|
|
34
|
+
import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Widgets (browser APIs, DOM libraries)
|
|
2
|
+
|
|
3
|
+
Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the `serve.ts` bundle
|
|
4
|
+
and the `@hozu/bundle` dependency. There is no `widget` export; the pieces are:
|
|
5
|
+
```ts
|
|
6
|
+
export const Map = ui.widget({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
|
|
7
|
+
events: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
|
|
8
|
+
load: 'visible', wraps: false }) // in declarations
|
|
9
|
+
ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
|
|
10
|
+
class: 'h-96 w-full' }, []) // in a view
|
|
11
|
+
```
|
|
12
|
+
```ts
|
|
13
|
+
// map.client.ts: a type-only import of the declaration
|
|
14
|
+
import { implement } from '@hozu/core/widget'
|
|
15
|
+
import type { Map } from './widgets.ts'
|
|
16
|
+
export default implement<typeof Map>(({ el, props, emit, signal }) => {
|
|
17
|
+
const map = createMap(el, props) // any DOM library
|
|
18
|
+
map.on('pick', (id) => emit('picked', { id }))
|
|
19
|
+
return { update(next) { map.move(next) }, destroy() { map.remove() } }
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
- `load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
|
|
23
|
+
- `serve.ts` passes `widgets: await bundleWidgets(build)` (the server refuses to start without it; `hozu build` bundles
|
|
24
|
+
them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
|
|
25
|
+
height class.
|
package/skill/patterns.md
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# Hozu patterns
|
|
2
|
-
|
|
3
|
-
Patterns marked *(example)* are used in `example/features/bookmarks/model.ts` and `views.ts`, next to this file;
|
|
4
|
-
read only the part you need.
|
|
5
|
-
|
|
6
|
-
- **Form with a server-side error** *(example)*:
|
|
7
|
-
- `ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [label, input, button])`.
|
|
8
|
-
- The machine goes to `adding`, which invokes the mutation. `failed.Duplicate` sets `ctx.error`.
|
|
9
|
-
- Show the error with `ui.if(op.neq(ctx.error, null), [ui.p({ role: 'alert' }, [ctx.error])], [])`.
|
|
10
|
-
- To clear the input after success, bind `value: ctx.draft` and reset `draft` in `done`.
|
|
11
|
-
- **Busy states (a mutation in flight)** *(example)*: render every control **once**. In each busy state, `ignore` the events
|
|
12
|
-
those controls send. Do not duplicate controls under `when`. Handling them there would re-enter the busy state
|
|
13
|
-
instead, and HZ005 would reject leaving them unhandled.
|
|
14
|
-
- **Filtering and empty state** *(example)*: `ui.each(visible({ items, show: ctx.show }), 'id', …)` and
|
|
15
|
-
`ui.if(isEmpty({ items, show: ctx.show }), [ui.p({}, ['No items'])], [ui.ul(...)])`, both using `fn`s.
|
|
16
|
-
- **Toggle buttons** (`aria-pressed`): `'aria-pressed': op.eq(ctx.show, s.value)` plus
|
|
17
|
-
`on: { click: ui.send(SetShow, { show: s.value }) }` for each option of a constant list.
|
|
18
|
-
- **Per-item action** *(example)*:
|
|
19
|
-
- `ui.send(ToggleRead, { id: item.id })` → a `toggling` state that stores `ctx.target` and invokes the mutation
|
|
20
|
-
with `{ id: ctx.target }`.
|
|
21
|
-
- Label text by data: `ui.if(op.eq(item.read, true), ['Mark unread'], ['Mark read'])`.
|
|
22
|
-
- **Select bound to an enum**:
|
|
23
|
-
`ui.select({ 'aria-label': 'Kind', on: { change: ui.send(PickKind, { kind: ui.dom.value }) } }, kinds.map((k) => ui.option({ value: k, selected: op.eq(ctx.kind, k) }, [k])))`,
|
|
24
|
-
where the event payload is `{ kind: Kind }`, the zod enum.
|
|
25
|
-
- **Detail page with a 404** *(example)*: a view with `route: itemPage` and no machine,
|
|
26
|
-
`ui.query(getItem, { id: params.id }, { ready, pending: null, failed: { NotFound: () => ..., Unexpected: () => ... } })`,
|
|
27
|
-
plus `head.query: getItem`.
|
|
28
|
-
- **Refresh after a mutation** *(example)*: tag the query, and list the tag in the mutation's `invalidates`. A mutation can
|
|
29
|
-
read only its input for tag params; use a list-wide tag when it affects many items.
|
|
30
|
-
|
|
31
|
-
- **Filter in the URL** *(example)* (shareable, works without JS): declare `search` on the route, render the options as
|
|
32
|
-
`ui.link(home, null, { show: s.value })` links with `'aria-current': op.eq(search.show, s.value)`, and filter with
|
|
33
|
-
`fn`s over `search.show`. Only use machine context for filters that should not survive a reload.
|
|
34
|
-
- **Go to what was just created** *(example)*: `done: [{ target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }]`.
|
|
35
|
-
- **No-JS form** *(example)*: every value the submit needs is a named field read with `ui.dom.form('name')`; the server runs the
|
|
36
|
-
machine for a native post. Per-item actions without JS: wrap the button in its own small form.
|
|
37
|
-
- **UI that survives following a link** (a cart, a player, a chat box): list the same
|
|
38
|
-
view with a machine on every page that should keep it, in the same order, e.g. `views: [ProductGrid, CartPanel]`
|
|
39
|
-
and `views: [ProductDetail, CartPanel]`. Links between those pages then swap only the other views; the kept view's
|
|
40
|
-
DOM and machine state stay. Nothing to declare: a view is kept only if it never reads `params`/`search` (neither
|
|
41
|
-
in its tree nor in its machine). `hozu plan <route>` lists what is kept per target route. Style the loading
|
|
42
|
-
state with `html[data-hozu-navigating]`.
|
|
43
|
-
- **Two languages**: `site.locales`, one `ui.messages` per feature, a language switcher of
|
|
44
|
-
`ui.a({ href: ui.alternate('en'), hreflang: 'en', lang: 'en' }, ['English'])` links, and `ui.format.date` for dates.
|
|
45
|
-
- **Load more / infinite scroll**: context `{ cursors: [null], last: null }`;
|
|
46
|
-
`ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => ... }))`; in the last page
|
|
47
|
-
(`op.and(op.eq(cursor, ctx.last), op.neq(page.next, null))`) render a button with `on: { click: ui.send(More,
|
|
48
|
-
{ cursor: page.next }) }` and a sentinel `ui.div({ class: 'h-px', on: { visible: ui.send(More, …) } }, [])`.
|
|
49
|
-
`More` appends the cursor and sets `last`, guarded by `op.and(op.neq(ctx.last, e.cursor), op.neq(e.cursor, null))`
|
|
50
|
-
so a page loads once. It needs JS; a list that must work without JS pages through `search` links.
|
|
51
|
-
- **Optimistic item** *(example)*: while the mutation runs, render the pending value from context
|
|
52
|
-
in the busy state: `when(['adding'], [ui.p({ class: 'opacity-50', 'aria-busy': 'true' }, ['Adding ', ctx.draft, '…'])])`.
|
|
53
|
-
Leaving the state (done or failed) removes it; the refreshed query shows the real item.
|
|
54
|
-
- **Field errors** *(example)*: context `fields: z.object({ title: z.string().nullable(), kind:
|
|
55
|
-
z.string().nullable() })`, reset it on submit, `failed.Invalid: [{ target: 'idle', assign: (e) => [op.set(ctx.fields,
|
|
56
|
-
e.fields)] }]`, and render `ui.p({ id: 'title-error' }, [ctx.fields.title])` with `'aria-invalid': op.neq(ctx.fields.title,
|
|
57
|
-
null)` on the input. It also works without JS.
|
package/skill/reference.md
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
# Hozu reference
|
|
2
|
-
|
|
3
|
-
Open the section the task needs. The core API is in `SKILL.md`.
|
|
4
|
-
|
|
5
|
-
## Routes
|
|
6
|
-
```ts
|
|
7
|
-
export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.array(z.string()).min(1) }), search: null })
|
|
8
|
-
```
|
|
9
|
-
- `:x` one segment (string), `:x?` optional (nullable string), `:x+` one or more / `:x*` zero or more (string[])
|
|
10
|
-
(HZ024).
|
|
11
|
-
- `search`: a flat object of scalars or enums, each with a default or nullable (HZ035); `null` = no query string.
|
|
12
|
-
- URLs are canonical: keys sorted, defaults left out. `ui.link(home, null, { show: 'unread' })`. Changing `search`
|
|
13
|
-
is a navigation, so a filter in the URL is a plain link and needs no machine.
|
|
14
|
-
|
|
15
|
-
## Views: events and DOM fields
|
|
16
|
-
- Any DOM event name, plus `visible` (the element entered the viewport).
|
|
17
|
-
- `ui.dom.value`: text. Into an enum field only from a `<select>` whose literal option values are all members
|
|
18
|
-
(HZ033).
|
|
19
|
-
- `ui.dom.form('name')`: a named field of the submitted form (on `submit`; the browser runs `required` /
|
|
20
|
-
`minlength` first). Into an enum field when the name belongs to a `<select>` (or radios) in the form whose
|
|
21
|
-
literal option values are all members, so one submit carries a title and a priority.
|
|
22
|
-
- `ui.dom.valueAsNumber` (number | null), `ui.dom.checked`, `ui.dom.key`, and similar event fields.
|
|
23
|
-
- Also: `ui.html(value)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png', import.meta.url))`,
|
|
24
|
-
`ui.window({ on })` / `ui.document({ on })` for global listeners, widgets (see Widgets) for browser APIs and
|
|
25
|
-
third-party DOM libraries.
|
|
26
|
-
|
|
27
|
-
## Widgets (browser APIs, DOM libraries)
|
|
28
|
-
There is no `widget` export: declare with `ui.widget`, place with `ui.use`, list the declaration in the feature's
|
|
29
|
-
`declarations`, and implement it in a client module.
|
|
30
|
-
```ts
|
|
31
|
-
export const Copy = ui.widget({ tag: 'button', props: z.object({ text: z.string() }),
|
|
32
|
-
events: { copied: z.object({}) }, client: new URL('./copy.client.ts', import.meta.url), load: 'visible', wraps: true })
|
|
33
|
-
ui.use(Copy, { props: { text: block.code }, on: { copied: () => ui.send(Copied, {}) }, class: 'btn' }, ['Copy'])
|
|
34
|
-
```
|
|
35
|
-
```ts
|
|
36
|
-
// copy.client.ts: a type-only import of the declaration
|
|
37
|
-
import { implement } from '@hozu/core/widget'
|
|
38
|
-
import type { Copy } from './widgets.ts'
|
|
39
|
-
export default implement<typeof Copy>(({ el, props, emit, signal }) => {
|
|
40
|
-
el.addEventListener('click', () => navigator.clipboard.writeText(props.text).then(() => emit('copied', {})), { signal })
|
|
41
|
-
return { update(next) { props = next } }
|
|
42
|
-
})
|
|
43
|
-
```
|
|
44
|
-
`load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
|
|
45
|
-
**Serving them is a separate step.** Run `npm install @hozu/bundle`, then pass `widgets: await bundleWidgets(build)`
|
|
46
|
-
to `createServer` in `serve.ts` (and to `exportStatic`). Without it the server refuses to start; `hozu build` bundles
|
|
47
|
-
widgets itself. HZ029 is a client module that does not bundle. A library's own CSS goes in `app.css`
|
|
48
|
-
(`@import "leaflet/dist/leaflet.css";`), and a map or chart host needs a height class (`h-96`).
|
|
49
|
-
|
|
50
|
-
## Forms without JavaScript
|
|
51
|
-
A submit whose payload reads only `ui.dom.form('name')`, literals, context, params and search also works without JS
|
|
52
|
-
(otherwise HZ036 warns). The server runs the same machine and mutation, then redirects (on `navigate`, or when the
|
|
53
|
-
machine is back where it started) or re-renders the page with the result (for example an error alert). Put every
|
|
54
|
-
value the submit needs in named fields: a `<select name="kind">`, not a separate change event.
|
|
55
|
-
|
|
56
|
-
## Field errors (`Invalid`)
|
|
57
|
-
Every mutation also has the framework error `Invalid` = `{ message, fields }`: one key per top-level input field
|
|
58
|
-
(`string | null`). It is returned when the input fails its schema (put limits there:
|
|
59
|
-
`z.string().min(2, 'Use at least 2 characters')`), and a resolver can return it:
|
|
60
|
-
`fail('Invalid', { message, fields: { title: 'Already taken' } })`.
|
|
61
|
-
- `failed.Invalid` is optional (without it, `Unexpected` handles it).
|
|
62
|
-
- With it: `assign: (e) => [op.set(ctx.fields, e.fields)]`, and show `ctx.fields.title` under the input with
|
|
63
|
-
`'aria-invalid': op.neq(ctx.fields.title, null)`.
|
|
64
|
-
- Never declare errors named `Invalid` or `Unexpected` yourself (HZ014).
|
|
65
|
-
|
|
66
|
-
## Pages
|
|
67
|
-
`ui.page(route, { views, head, entries?, assert? })`.
|
|
68
|
-
- `head.render` returns `{ title, description?, type?: 'website' | 'article', image?, published?, noindex? }`.
|
|
69
|
-
`head.query` + `head.input: (params, locale) => …` load data for it; a failing head query sets the HTTP status
|
|
70
|
-
(NotFound → 404). `head.redirects` maps declared errors to routes.
|
|
71
|
-
- `entries: { query, input, params: (item) => … }` lists the pages of a route with params for the sitemap.
|
|
72
|
-
- `project({ notFound: route, error: route })` renders those pages for 404 / 500.
|
|
73
|
-
- A view listed with a machine on several pages, in the same order, stays mounted when links move between them
|
|
74
|
-
(see `patterns.md`).
|
|
75
|
-
- **JS per page is derived:** a page loads the client only when a machine-bound node renders on it. An island
|
|
76
|
-
inside `ui.each`, `ui.if`, `when` or a query branch loads it only on pages where it renders; `hozu plan <route>`
|
|
77
|
-
says `always` or `only when rendered`. Do not add views or flags to avoid JS.
|
|
78
|
-
|
|
79
|
-
## Sessions
|
|
80
|
-
- **Start from the scaffold:** `hozu add feature notes --page / --with auth` writes `features/account`
|
|
81
|
-
(sign-in page, sign-out, `me`), the session cookie in `serve.ts`, per-user resolvers, and a redirect to `/login`
|
|
82
|
-
when signed out. Replace the name-only sign-in with real credentials before production; set `SESSION_SECRET`
|
|
83
|
-
(and `SESSION_SECURE=true` behind HTTPS).
|
|
84
|
-
- `project({ session: z.object({ user: z.string() }) })` declares the identity. Queries with `scope: 'user'` and
|
|
85
|
-
mutations receive `session`; public resolvers never do.
|
|
86
|
-
- `createServer({ session: (request) => value })`, or `sessionCookie({ name, secret })` from
|
|
87
|
-
`@hozu/runtime-server` for a signed cookie. Mutations can call `setSession(value)`.
|
|
88
|
-
|
|
89
|
-
## Languages (i18n)
|
|
90
|
-
- `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`). Routes and
|
|
91
|
-
`ui.link` stay locale-free; links keep the current locale. `/` and locale-less URLs redirect by
|
|
92
|
-
`Accept-Language`. `<html lang>`, hreflang, og:locale and the sitemap are derived.
|
|
93
|
-
- Text: `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`,
|
|
94
|
-
added to the feature's `declarations`. Use `text.title` or `text.saved({ count })` in views and `head.render`.
|
|
95
|
-
Every locale needs every key with the same `{placeholders}` (HZ040). Plurals:
|
|
96
|
-
`'{n, plural, =0 {none} one {# item} other {# items}}'`; `select` also works.
|
|
97
|
-
- Machines never hold translated text (HZ041): store a code (`'duplicate'`) and pick the message in the view.
|
|
98
|
-
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
|
99
|
-
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`.
|
|
100
|
-
- `locale` is in every view scope and the second argument of `head.input`. `ui.alternate('zh-TW')` is the current
|
|
101
|
-
page in another locale.
|
|
102
|
-
|
|
103
|
-
## Environment
|
|
104
|
-
`project({ env: { server: z.object({ DB_URL: z.string() }), public: z.object({ SUPPORT_EMAIL: z.string().email() }) } })`.
|
|
105
|
-
Both are parsed when the server starts (defaults and `z.coerce` apply; a missing value stops startup). Resolvers get
|
|
106
|
-
`ctx.env` (server values). Views read public values with `ui.env(PublicEnv).SUPPORT_EMAIL`. Machines cannot read env
|
|
107
|
-
(HZ041).
|
|
108
|
-
|
|
109
|
-
## HTTP
|
|
110
|
-
Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
|
|
111
|
-
```ts
|
|
112
|
-
http: {
|
|
113
|
-
basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
|
|
114
|
-
trailingSlash: 'always', // or 'never'; the other form answers 308
|
|
115
|
-
redirects: { // keyed by the old path; never a path a page owns (HZ037)
|
|
116
|
-
'/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true }, // 308
|
|
117
|
-
'/docs': { to: 'https://docs.example.com', permanent: false }, // 307
|
|
118
|
-
},
|
|
119
|
-
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // or routes: [post]; not cache-control (HZ038)
|
|
120
|
-
},
|
|
121
|
-
```
|
|
122
|
-
There are no rewrites: one URL has one owner.
|
|
123
|
-
|
|
124
|
-
## Server options
|
|
125
|
-
`createServer({ build, styles, resolvers, session?, onError?, csp?, images?, og?, preview? })` from
|
|
126
|
-
`@hozu/adapter-node`.
|
|
127
|
-
- `onError(error, { effect | path })` receives every unexpected failure.
|
|
128
|
-
- A strict CSP, `nosniff` and a cross-site POST check are on by default (`csp` adds sources, e.g.
|
|
129
|
-
`{ script: ['https://analytics.example'] }`, or `false`).
|
|
130
|
-
- Test a mutation with curl:
|
|
131
|
-
`curl -X POST localhost:4700/_hozu/effect -H 'content-type: application/json' -d '{"effect":"items.addItem","input":{"title":"x"},"keys":[]}'`.
|
|
132
|
-
|
|
133
|
-
## Content, images, share images, fonts
|
|
134
|
-
- **Markdown:** `@hozu/content` turns `content/posts/*.md` (YAML front matter checked by a schema) into
|
|
135
|
-
`{ slug, data, html, headings }`: `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`
|
|
136
|
-
in `server.ts`, returned from ordinary query resolvers; render the body with `ui.html(post.html)`.
|
|
137
|
-
- **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
|
|
138
|
-
dimensions). With `@hozu/image` installed, pass `images: await optimizeImages(build)` to `createServer` (and
|
|
139
|
-
`hozu build` does it itself): raster assets get WebP `srcset` widths and `sizes`.
|
|
140
|
-
- **Share images:** `head.render` → `image: ui.og({ title, subtitle })` renders a 1200×630 card; pass
|
|
141
|
-
`og: ogImage` (from `@hozu/image`) to `createServer`. On a static host, use a file instead:
|
|
142
|
-
`image: ui.asset(new URL('./share.png', import.meta.url))` (made absolute with `site.url`).
|
|
143
|
-
- **Fonts:** a local `@font-face` gets a size-matched `"<Family> Fallback"` automatically.
|
|
144
|
-
- **Page transitions:** the stylesheet turns on cross-document view transitions, so links between pages cross-fade
|
|
145
|
-
instead of flashing (no JS). Turn them off with `@view-transition { navigation: none; }` in `app.css`; style them
|
|
146
|
-
with `::view-transition-*`.
|
|
147
|
-
|
|
148
|
-
## Preview (drafts)
|
|
149
|
-
`createServer({ preview: { secret } })`; `GET /_hozu/preview?secret=…&path=/posts/a` turns preview on (a signed
|
|
150
|
-
cookie), `/_hozu/preview/exit` turns it off. Resolvers get `ctx.preview`; preview responses are never cached and are
|
|
151
|
-
noindex.
|
|
152
|
-
|
|
153
|
-
## PWA and offline
|
|
154
|
-
A web app manifest is derived from `site` (`name`, `themeColor`, `icon`). `site.offline: route` is a static page
|
|
155
|
-
shown when the network is down; a service worker is generated (HZ043: no params, no per-request data).
|
|
156
|
-
|
|
157
|
-
## Testing rendered pages
|
|
158
|
-
`const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` gives
|
|
159
|
-
`{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form. In a browser test (Playwright),
|
|
160
|
-
wait for `html[data-hozu-ready]` before clicking: it is set when the page has hydrated.
|
|
161
|
-
|
|
162
|
-
## Deployment
|
|
163
|
-
`hozu build` writes `dist/public/` (static files for any host or CDN) and `dist/manifest.json`. On Node:
|
|
164
|
-
`createServer({ build: buildProject(project, { manifest }), manifest, publicDir: 'dist/public', … })`. On Bun, Deno,
|
|
165
|
-
Cloudflare Workers or Vercel the server is `createHandler({ build, manifest, resolvers, render })` from
|
|
166
|
-
`@hozu/runtime-server` with `export default { fetch: handler.fetch }`, where
|
|
167
|
-
`import * as render from './dist/server/render.js'` is the page code `hozu build` generates (edge runtimes cannot
|
|
168
|
-
generate it at startup). Page cache and tag revalidation are per instance.
|
|
169
|
-
A fully static site (GitHub Pages, any file host): `exportStatic({ build, styles, resolvers, outDir })` from
|
|
170
|
-
`@hozu/adapter-static` writes every page without per-request data, plus the files they link to, and lists skipped
|
|
171
|
-
routes.
|
|
172
|
-
```ts
|
|
173
|
-
import manifest from './dist/manifest.json' with { type: 'json' }
|
|
174
|
-
import * as render from './dist/server/render.js'
|
|
175
|
-
const handler = createHandler({ build: buildProject(project, { manifest }), manifest, render, resolvers: createResolvers() })
|
|
176
|
-
export default { fetch: handler.fetch }
|
|
177
|
-
```
|