create-hozu 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +69 -154
- package/skill/changing.md +15 -18
- package/skill/example/features/bookmarks/model.ts +51 -23
- package/skill/example/features/bookmarks/views.ts +10 -14
- package/skill/topics/auth.md +12 -0
- package/skill/topics/content.md +14 -0
- package/skill/topics/contracts.md +21 -0
- package/skill/topics/data.md +35 -0
- package/skill/topics/deploy.md +12 -0
- package/skill/{diagnostics.md → topics/diagnostics.md} +8 -4
- package/skill/topics/endpoints.md +12 -0
- package/skill/topics/env.md +5 -0
- package/skill/topics/forms.md +24 -0
- package/skill/topics/http.md +17 -0
- package/skill/topics/i18n.md +11 -0
- package/skill/topics/machine.md +40 -0
- package/skill/topics/pages.md +39 -0
- package/skill/topics/patterns.md +45 -0
- package/skill/topics/testing.md +21 -0
- package/skill/topics/views.md +34 -0
- package/skill/topics/widgets.md +28 -0
- package/templates/guide.md +1 -0
- package/skill/patterns.md +0 -57
- package/skill/reference.md +0 -177
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
|
-
```
|