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.
@@ -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
- ```