@hozu/cli 0.18.2 → 0.20.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/agent.d.ts +19 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +33 -0
- package/dist/agent.js.map +1 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +1 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/browse-page.d.ts.map +1 -1
- package/dist/commands/browse-page.js +8 -2
- package/dist/commands/browse-page.js.map +1 -1
- package/dist/commands/browse-tab.d.ts +10 -0
- package/dist/commands/browse-tab.d.ts.map +1 -1
- package/dist/commands/browse-tab.js +44 -1
- package/dist/commands/browse-tab.js.map +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +42 -3
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/docs.js +1 -1
- package/dist/commands/map.js +2 -2
- package/dist/commands/map.js.map +1 -1
- package/dist/commands/scaffold.js +8 -8
- package/dist/commands/scaffold.js.map +1 -1
- package/dist/commands/skill.js +1 -1
- package/dist/contract.d.ts +4 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/guide.d.ts +27 -0
- package/dist/guide.d.ts.map +1 -0
- package/dist/guide.js +48 -0
- package/dist/guide.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +6 -5
- package/dist/main.js.map +1 -1
- package/dist/migrate/steps.d.ts.map +1 -1
- package/dist/migrate/steps.js +14 -0
- package/dist/migrate/steps.js.map +1 -1
- package/package.json +13 -9
- package/schema/browse.schema.json +13 -0
- package/schema/inspect.schema.json +65 -1
- package/skill/SKILL.md +64 -0
- package/skill/example/app.css +1 -0
- package/skill/example/app.ts +35 -0
- package/skill/example/features/bookmarks/feature.ts +13 -0
- package/skill/example/features/bookmarks/model.ts +163 -0
- package/skill/example/features/bookmarks/views.ts +156 -0
- package/skill/example/hozu.config.ts +34 -0
- package/skill/example/previews.ts +13 -0
- package/skill/example/routes.ts +11 -0
- package/skill/example/ui/badge.ts +11 -0
- package/skill/example/ui/button.ts +23 -0
- package/skill/example/ui/field.ts +20 -0
- package/skill/example/ui/input.ts +33 -0
- package/skill/example/ui/kit.ts +7 -0
- package/skill/example/ui/tv.ts +12 -0
- package/skill/topics/auth.md +56 -0
- package/skill/topics/components.md +92 -0
- package/skill/topics/content.md +37 -0
- package/skill/topics/contracts.md +36 -0
- package/skill/topics/data.md +75 -0
- package/skill/topics/deploy.md +73 -0
- package/skill/topics/diagnostics.md +99 -0
- package/skill/topics/endpoints.md +39 -0
- package/skill/topics/env.md +44 -0
- package/skill/topics/feature.md +90 -0
- package/skill/topics/fetch.md +66 -0
- package/skill/topics/forms.md +47 -0
- package/skill/topics/http.md +21 -0
- package/skill/topics/i18n.md +34 -0
- package/skill/topics/machine.md +83 -0
- package/skill/topics/pages.md +69 -0
- package/skill/topics/patterns.md +85 -0
- package/skill/topics/recipes.md +79 -0
- package/skill/topics/requests.md +41 -0
- package/skill/topics/testing.md +82 -0
- package/skill/topics/views.md +53 -0
- package/templates/guide.md +16 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Deployment
|
|
2
|
+
|
|
3
|
+
- **One app module:** `project({ app: new URL('./app.ts', import.meta.url) })`; `app.ts` default-exports
|
|
4
|
+
`app({ resolvers, session?, components?, … })` from `@hozu/runtime-server`. `hozu serve`, `hozu check`, `hozu get`,
|
|
5
|
+
`hozu browse` and `testApp(app)` all run it, so what the tools verify is what production serves.
|
|
6
|
+
- **Which host:** nobody marks pages static. `npx hozu export` writes every page for a static host (GitHub Pages,
|
|
7
|
+
Netlify, Cloudflare Pages, Vercel) to `dist/` and exits 1 naming each page or effect that needs a server; then
|
|
8
|
+
use Node (`npm start`, a Docker image) or Cloudflare Workers.
|
|
9
|
+
- **Node:** `npm start` is `hozu serve` (adapter-node on `PORT`, `HOST`). Docker: `node:22-slim`, `npm ci
|
|
10
|
+
--omit=dev`, `CMD ["npx", "hozu", "serve"]`.
|
|
11
|
+
- Set `SESSION_SECRET` when the app has sessions: `npm start` (`hozu serve`) runs as production unless `NODE_ENV` is set, and production refuses to start without it. `hozu dev`, `get`, `browse` and `call` do not need it.
|
|
12
|
+
- Workers, several instances, sessions in KV, upgrading: see --more.
|
|
13
|
+
|
|
14
|
+
<!-- more -->
|
|
15
|
+
|
|
16
|
+
## Details
|
|
17
|
+
- **App options:** `app({ resolvers: resolvers(project, (implement) => [...]), session?, components?, og?, csp?,
|
|
18
|
+
onError?, preview? })`. There is no wrapper position: headers go through `project({ http })`, statuses through
|
|
19
|
+
`head.failed` and endpoint `failed`, the language through the URL.
|
|
20
|
+
- **Node:** adapter-node serves `process.env`, styles and every `ui.asset` (hashed under `/_hozu/a/`). There is no
|
|
21
|
+
`public/` folder served at the root: a file the page shows is a `ui.asset(new URL(...))`; a file named in data
|
|
22
|
+
(a cover in front matter) is served by a GET endpoint with `output: 'response'`. `hozu build` writes
|
|
23
|
+
`dist/public/`, `dist/manifest.json` and `dist/server/render.js`. It compresses answers as they stream (gzip),
|
|
24
|
+
and framework files with brotli or gzip from the `.br` / `.gz` that `hozu build` writes. Live streams are not
|
|
25
|
+
compressed. The edge handler leaves compression to the platform.
|
|
26
|
+
- **Edge (Cloudflare Workers, Bun, Deno):** `hozu build --out build` (git-ignore `build/`), then bundle an entry with esbuild and
|
|
27
|
+
`hozuTransform()` from `@hozu/transform/esbuild` (it gives each app file its own `import.meta.url`, which a
|
|
28
|
+
Worker lacks). The entry creates the handler on the first request, when the platform's `env` is known:
|
|
29
|
+
`handler ??= createHandler(app, { manifest, render, env })` from `@hozu/runtime-server`, with
|
|
30
|
+
`import * as render from './build/server/render.js'`. Serve `build/public` as static assets (wrangler
|
|
31
|
+
`[assets] directory`). Workers keep no memory between requests: data goes in a database.
|
|
32
|
+
- **Static host:** `npx hozu export [--out dist]` (`@hozu/adapter-static`, in new apps) empties the folder, writes
|
|
33
|
+
every page without per-request server data plus `.nojekyll`, and prints what it skipped. Pages whose data runs in
|
|
34
|
+
the browser (`runs: 'browser'` / `'either'`) export completely; a page that calls a server effect is listed
|
|
35
|
+
(HZ082). A GitHub project site sets `project({ http: { basePath: '/<repo>' } })` and uploads `dist/<repo>`.
|
|
36
|
+
In code: `exportStatic({ build, styles, resolvers, outDir })`.
|
|
37
|
+
- **Edge and fetch.ts:** a host without `import()` of files passes `createHandler(app, { fetches: async (f) =>
|
|
38
|
+
modules[f] })` for `runs: 'either'` effects.
|
|
39
|
+
- **Sessions:** the default store keeps sessions in memory per process. Several instances or Workers share
|
|
40
|
+
`kvSessions(kv, { secret })` (`@hozu/runtime-server`; `kv` has Cloudflare KV's `get` / `put(key, value,
|
|
41
|
+
{ expirationTtl })` / `delete`, so a KV binding fits as is; wrap Redis in those three). On Workers pass it to the
|
|
42
|
+
handler: `createHandler(app, { manifest, render, env, session: kvSessions(env.SESSIONS, { secret:
|
|
43
|
+
env.SESSION_SECRET }) })`. Cloudflare KV may take up to a minute to show a sign-out in other regions.
|
|
44
|
+
- **Another store:** implement `SessionStore` (`read`, `write`, `issue`) and pass it as `app({ session })` or the
|
|
45
|
+
handler's `session`; keep the cookie an opaque signed id (ADR 0043 B).
|
|
46
|
+
|
|
47
|
+
## Caches and many instances
|
|
48
|
+
- **Bounded caches:** public query results and cached pages are LRU caches, at most 10,000 entries and 5,000 pages
|
|
49
|
+
per process. Change the bounds with `app({ dataCache: memoryDataCache({ maxEntries }) })` (`@hozu/data`) and
|
|
50
|
+
`app({ cache: memoryCache({ maxPages }) })` (`@hozu/runtime-server`). `server.stats()` reports
|
|
51
|
+
`{ dataEntries, pages, evictions }`.
|
|
52
|
+
- **More than one instance:** each instance caches on its own, so a mutation on one must tell the others. Pass
|
|
53
|
+
`app({ bus: httpBus({ peers: [every instance's base URL, this one included], secret: process.env.BUS_SECRET }) })`
|
|
54
|
+
(`@hozu/runtime-server`; signed `POST /_hozu/invalidate`, 32+ character secret). The others drop the tagged pages
|
|
55
|
+
and data and push to their live clients. Messages carry tags, never data.
|
|
56
|
+
- **A broker instead of peer URLs:** implement `InvalidationBus` (`publish(tags)`, `subscribe(onTags)`):
|
|
57
|
+
```ts
|
|
58
|
+
const pub = createClient({ url }); const sub = pub.duplicate() // e.g. redis
|
|
59
|
+
await Promise.all([pub.connect(), sub.connect()])
|
|
60
|
+
const bus: InvalidationBus = {
|
|
61
|
+
publish: (tags) => void pub.publish('hozu', JSON.stringify(tags)),
|
|
62
|
+
subscribe: (onTags) => {
|
|
63
|
+
void sub.subscribe('hozu', (m) => onTags(JSON.parse(m)))
|
|
64
|
+
return () => void sub.unsubscribe('hozu')
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
- `app({ staticTtl: 300 })` re-reads `'static'` data and pages after 300 s, in case a bus message is lost; off by
|
|
69
|
+
default.
|
|
70
|
+
|
|
71
|
+
## Upgrading Hozu
|
|
72
|
+
`npx -p @hozu/cli@latest hozu migrate` (preview with `--dry-run`), then run the `next:` lines it prints: install, and
|
|
73
|
+
`npx hozu migrate` again, which checks the IR is unchanged and runs `hozu check`. Never raise `@hozu/*` by hand.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Hozu diagnostics
|
|
2
|
+
|
|
3
|
+
Every diagnostic carries `file:line`, a cause and a fix, and often a snippet or patch. Apply the fix; do not work
|
|
4
|
+
around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and the topic to read.
|
|
5
|
+
|
|
6
|
+
- Errors fail `hozu check`. Warnings (HZ010, HZ019, HZ025, HZ036, HZ056, HZ058, HZ061, HZ062, HZ063, HZ075, HZ076, HZ077, HZ080, HZ083, HZ084, HZ086, HZ087, HZ089, HZ090) do not, but each one names something to decide.
|
|
7
|
+
- A warning you keep on purpose goes in `project({ accept: [{ code, at, reason }] })`; errors cannot be accepted.
|
|
8
|
+
|
|
9
|
+
<!-- more -->
|
|
10
|
+
|
|
11
|
+
| Code | Meaning | Usual fix |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| HZ001 | state unreachable | add a transition to it or delete it |
|
|
14
|
+
| HZ002 | event handled nowhere | handle it in a state or remove it |
|
|
15
|
+
| HZ003 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
16
|
+
| HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
|
|
17
|
+
| HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | handle it there (`machine({ on })` handles it in every state), show the node only via `when`, or `ignore: [Event]` to drop it |
|
|
18
|
+
| HZ006 | crossing a feature boundary | import the feature and use its `exports` |
|
|
19
|
+
| HZ007 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
|
|
20
|
+
| HZ008 | a path does not exist in the schema | fix the property name |
|
|
21
|
+
| HZ009 | a guardless transition shadows later ones | put guarded transitions first |
|
|
22
|
+
| HZ010 (warning) | a state has no way out (not final; no `on`, `invoke` or `after`) | mark it `final: true` or add a transition out of it |
|
|
23
|
+
| HZ011 | building twice gave different IR | keep time, randomness and mutable state out of builder callbacks |
|
|
24
|
+
| HZ012 | a schema from another library than the project's adapter | write it with the project's schema library (`project({ schema })`) |
|
|
25
|
+
| HZ013 | a declaration registered twice: a second machine (or messages) in a feature, or a name another feature already declares | keep one; reach the other feature through `imports` and its `exports` |
|
|
26
|
+
| HZ014 | wrong builder output, or a method called on data (`.map`, `.toUpperCase()`) | follow the builder signature; lists: `ui.each`; computation: a `fn()` |
|
|
27
|
+
| HZ015 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
28
|
+
| HZ016 | a transition that decides (guard, `navigate`, a `fn`, comparison or `+ - ?? ?: .length .includes` in a value) has no contract | add the contract from the snippet |
|
|
29
|
+
| HZ017 | a contract fails / contract data does not match its schema | fix the machine or the contract (decide the intended behaviour first) |
|
|
30
|
+
| HZ018 | a deciding transition changed (fields first, then `was:` / `now:`) and no covering contract fails against the old behaviour | change or add a contract that specifies the new behaviour; renaming or copying one does not count |
|
|
31
|
+
| HZ019 (warning) | `invalidates` or `refresh` names a tag no query carries (or, for `refresh`, only server-cached ones) | tag the affected query, or remove the invalidation; a refreshed query reads with `freshness: 'request'` |
|
|
32
|
+
| HZ020 | user-scoped data, but the project declares no session | declare `project({ session })`, or make the query public |
|
|
33
|
+
| HZ021 | a query, mutation or endpoint without a resolver | `implement(...)` it in the resolvers of `app.ts` |
|
|
34
|
+
| HZ022 | user data in a cacheable region | keep `scope: 'user'` queries out of cached pages |
|
|
35
|
+
| HZ023 | a page's `assert` does not hold for its derived render plan | change the data's scope or freshness, or the assertion |
|
|
36
|
+
| HZ024 | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
|
|
37
|
+
| HZ025 (warning) | route params mismatch (keys, or a schema that does not fit `:x?`/`:x+`/`:x*`) / page with params but no `entries` (a user-scoped head is private: no sitemap, no warning) | align them / add `entries` |
|
|
38
|
+
| HZ026 | a class produces no CSS | fix the Tailwind class |
|
|
39
|
+
| HZ027 | a DOM field used outside an event, or wrong for this event | read `ui.dom.*` only in `ui.send` payloads |
|
|
40
|
+
| HZ028 | `img` without width/height | add both |
|
|
41
|
+
| HZ029 | a client component's module is missing, or a handler for an event it does not emit | create the module (`hozu add component … --client`); handle declared `emits` only |
|
|
42
|
+
| HZ030 | `ui.html` of untrusted data | render text instead |
|
|
43
|
+
| HZ031 | a literal not allowed by its schema | use an allowed value (the patch suggests one) |
|
|
44
|
+
| HZ032 | internal link or form action written as a string | `ui.link(route, params)` / `ui.link(endpoint)` |
|
|
45
|
+
| HZ033 | DOM text into an enum, number or boolean field | a `<select>`, radios or submit buttons with enum values; in a form, a flag through `ui.dom.formAll` and numbers parsed in the mutation input |
|
|
46
|
+
| HZ034 | a state both handles and ignores an event | remove it from one of the two |
|
|
47
|
+
| HZ035 | search schema is not a flat object of scalars with defaults | `z.object({ key: scalar.default(…) })` |
|
|
48
|
+
| HZ036 (warning) | a form submitted before the page has loaded is lost: its payload reads DOM values other than its named fields | read its values with `ui.dom.form('name')` / `ui.dom.formAll('name')` |
|
|
49
|
+
| HZ037 | a redirect is not a path, hides a page or another redirect, or targets an unknown route | change or remove the `from` key; point `to` at `ui.link(...)` |
|
|
50
|
+
| HZ038 | `http.headers` sets a header the framework owns, or an invalid name/value | remove it (`cache-control` is derived; CSP is `app({ csp })`) |
|
|
51
|
+
| HZ039 | `basePath` is not `''` or `/segment[/segment…]` | e.g. `'/shop'`, no trailing slash |
|
|
52
|
+
| HZ040 | a locale lacks a message, or uses other `{placeholders}` | add/translate the key in that locale |
|
|
53
|
+
| HZ041 | a machine uses a message, `ui.format` or `locale` | store a code in context; choose the message in the view |
|
|
54
|
+
| 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'`) |
|
|
55
|
+
| HZ043 | `site.offline` has params, no page, or per-request data | point it at a static page, or remove `offline` |
|
|
56
|
+
| 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 |
|
|
57
|
+
| HZ045 | no `project({ app })`, its default export is not `app(…)`, or views use client components and `app()` has no bundle | `export default app({ resolvers, components: bundleComponents })` |
|
|
58
|
+
| HZ046 | an endpoint path is reserved, has params or collides; an error without a status, or with one an endpoint error cannot answer; a form posting to it with another method or an undeclared field | a static path such as `/api/…` (patch); map every error in `failed` to 400, 401, 403, 404, 409, 410, 422 or 429 |
|
|
59
|
+
| HZ047 | a `fn` body uses an imported name or `let` state (it is sent to the browser as source) | pass the value as input, or write it as a `const` helper in the module |
|
|
60
|
+
| HZ048 | `seed` names a field the context lacks, has no machine or route, or two views on one page seed a machine | seed top-level context fields, on one view per page |
|
|
61
|
+
| HZ049 | a `scope: 'user'` query is cached (`'static'`, `revalidate`, `swr`) | `freshness: 'request'` (patch), or `'live'` for push |
|
|
62
|
+
| HZ050 | a `'live'` query has no tags | add the tags its writers invalidate, or use `'request'` |
|
|
63
|
+
| HZ051 | `head.failed` misses a declared error of the head query, or maps another one | choose per error: a route (303), `403`, `404` or `410` (an intent decision: no patch) |
|
|
64
|
+
| HZ052 | a route that no page renders | link to the endpoint with `ui.link(endpoint, input)` (patch), or add its `ui.page` |
|
|
65
|
+
| HZ053 | (runtime) an endpoint answered `text/html`: a 500 | make it a `ui.page`; statuses and redirects go through `head.failed` |
|
|
66
|
+
| HZ054 | `ui.dom.form` reads one value of a list field or of a repeated name | `ui.dom.formAll('name')` (patch) |
|
|
67
|
+
| HZ055 | a form read names no control of the form | the name it suggests (patch), or add the control |
|
|
68
|
+
| HZ056 (warning) | a submit button also sends on click | `name`/`value` on the button, read in submit; or `type: 'button'` |
|
|
69
|
+
| HZ057 | `hozu.lock.json` differs from the computed lock (new, removed or copy-only changes, contract maps, a missing or 0.7 file) | if intended, `hozu check --update-lock`, then list the accepted `now:` lines in your summary |
|
|
70
|
+
| HZ058 (warning) | contracts that fire only copy-only transitions and evaluate no guard | none needed: the lock entries it names review those transitions |
|
|
71
|
+
| HZ059 | data reached plain JavaScript: a plain helper, a global (`Boolean`, `Object.keys`, `String`…), `typeof`, a spread or `in` | make the helper a `part()`; for a global use an operator or a `fn()` |
|
|
72
|
+
| HZ060 | a page route starts with a locale segment (`/de/…` under `site.locales`) | rename the route (patch); the locale prefix is added for you |
|
|
73
|
+
| HZ061 (warning) | a form-fed event payload declares limits | move them to the mutation input |
|
|
74
|
+
| HZ062 (warning) | a GET endpoint declares `invalidates` | `method: 'POST'`, or keep it on purpose (e-mail links) |
|
|
75
|
+
| HZ063 (warning) | as HZ055, in a form holding `ui.html`, a client component or another view | as HZ055 |
|
|
76
|
+
| HZ064 | two contracts with identical IR | remove one (patch) |
|
|
77
|
+
| HZ070 | a component's render references a declaration (event, query, route, message…) | pass a `Send` through `on`, an `Href` prop, text as a prop or slot |
|
|
78
|
+
| HZ071 | a variant from data | make it a prop, styled through an attribute (`aria-pressed:`, `data-[x=y]:`) |
|
|
79
|
+
| HZ072 | a caller's `class` sets a property the component owns | declare a variant; a one-off ends with `!` (snippet) |
|
|
80
|
+
| HZ073 | `!` inside a component / a leading `!x` | remove it (patch in the tv config, snippet in the render) / write `x!` (patch) |
|
|
81
|
+
| HZ074 | `!` inside a component / a leading `!x` | remove it (patch in the tv config, snippet in the render) / write `x!` (patch) |
|
|
82
|
+
| HZ075 (warning) | a caller's inherited class (colour, font) is hidden by an inner element | a variant, or style the inner element |
|
|
83
|
+
| HZ076 (warning) | a component owns a margin | remove it (patch); outer spacing is the caller's |
|
|
84
|
+
| HZ077 (warning) | `!` on a property the component does not own | remove the `!` (patch) |
|
|
85
|
+
| HZ078 | the kit's `tv.ts` config differs from the design system | `hozu add kit <id> --sync` |
|
|
86
|
+
| HZ079 | two classes of one element set the same property | the patch: a complementary toggle, or remove the one that never wins |
|
|
87
|
+
| HZ080 (warning) | a `part()` view inlined by two features | the snippet: the same `ui.component` in a kit |
|
|
88
|
+
| HZ081 | an effect's `runs` and `fetch.ts` disagree: no export, an extra one, a `'server'` effect in it, `'either'` with user data, a Node-only import | export the effect in `fetch.ts`, or `runs: 'server'` with a resolver (`hozu docs fetch`) |
|
|
89
|
+
| HZ082 | browser data where only the server can go (a page `head`, `entries`), or a browser mutation invalidating a server-cached tag | the patch: `runs: 'server'`; or `freshness: 'request'` on the cached query |
|
|
90
|
+
| HZ083 (warning) | fetch.ts calls an origin (an absolute URL in it) that the feature's `connect` does not list: the browser's CSP blocks it | add the origin to `feature({ connect })` (the fix lists the whole line); `{ env: 'NAME' }` for a URL from public env |
|
|
91
|
+
| HZ084 (warning) | a public env variable named like a secret (`SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE`, `…_KEY`): public values reach the browser | move it to `env.server`; only a value made to be published (a publishable key) stays public, named `PUBLIC_…` |
|
|
92
|
+
| HZ085 | `env.internal` maps a name that is not a public variable, or to one that is not a server variable; or `site.url: { env }` names an undeclared variable | declare both: the public URL in `env.public`, the internal one in `env.server` |
|
|
93
|
+
| HZ086 (warning) | an env file listed in `env.files` exists and git does not ignore it | add it to `.gitignore`; commit `.env.example` (`npx hozu env --example`) instead |
|
|
94
|
+
| HZ087 (warning) | an entry of `project({ accept })` matches no warning, names an error, or has no reason | remove the entry when the warning is gone; fix an error instead of accepting it; give every entry a reason |
|
|
95
|
+
| HZ088 | a server-run `scope: 'user'` query or a server-run mutation without `access`, or an access rule that reads a field the output or session does not have | say who may run it: `access: 'signedIn'`, `{ owner: { row, session } }`, `{ allow: ({ session }) => … }`, or `'anyone'` |
|
|
96
|
+
| HZ089 (warning) | `access` on a public query or a browser-run effect, where the server cannot enforce it | remove it: a public query never sees the session, and a browser-run effect is guarded by the API it calls |
|
|
97
|
+
| HZ090 (warning) | `access: 'anyone'` on a `scope: 'user'` query: every visitor, signed in or not, may read it | say who may read it (`'signedIn'`, `{ owner: { row, session } }`), or accept the warning with a reason |
|
|
98
|
+
| HZ091 | a query with `owner` access returned rows the visitor does not own (reported at run time) | read only the visitor's rows in the resolver (filter by the session); production drops the extra rows and logs this |
|
|
99
|
+
| HZ092 | a preview in `project({ previews })` no longer fits the app: data off its query output schema, an error the query does not declare, a route without a page, or a component use that does not build | update the preview to the current schema, error, page or component (previews are for people: they never ship) |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Endpoints (webhooks, JSON APIs, auth callbacks, downloads)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const exportNotes = endpoint({ method: 'GET', path: '/api/export', // exported from model.ts
|
|
5
|
+
input: z.object({ format: z.enum(['json', 'csv']).default('json') }), output: z.object({ notes: z.array(Note) }),
|
|
6
|
+
errors: { Unauthorized: z.object({ message: z.string() }) }, failed: { Unauthorized: 401 } })
|
|
7
|
+
implement(exportNotes, (input, { session, fail }) => // in the app's resolvers
|
|
8
|
+
session ? { notes: notesOf(session.user) } : fail('Unauthorized', { message: 'Sign in' }))
|
|
9
|
+
```
|
|
10
|
+
- GET input comes from the query string (coerced by the schema), POST input from a JSON or form body; invalid input
|
|
11
|
+
answers 400. `fail(name, payload)` answers `failed[name]`; every declared error is mapped (HZ046).
|
|
12
|
+
- `output`: a schema (sent as JSON), `'redirect'` (return `redirect(ui.link(route, params))` from the context, 303) or
|
|
13
|
+
`'response'` (a web `Response`, for files). Never HTML: a page is a `ui.page` (HZ053).
|
|
14
|
+
- Links: `ui.link(getEndpoint, input)` is the URL of a GET endpoint; `ui.form({ method: 'post', action:
|
|
15
|
+
ui.link(postEndpoint) }, [...])` posts a native form, whose fields the input declares (HZ046).
|
|
16
|
+
- Paths are static and outside pages, redirects, `/_hozu/` and the derived `/sitemap.xml`, `/robots.txt` and
|
|
17
|
+
`/manifest.webmanifest` (HZ046).
|
|
18
|
+
|
|
19
|
+
<!-- more -->
|
|
20
|
+
|
|
21
|
+
- Error bodies: invalid input is `{ error: 'Invalid', message, fields }`; a `fail` is `{ error, message, ...every field its error schema declares }`
|
|
22
|
+
(statuses 400 401 403 404 409 410 422 429).
|
|
23
|
+
- `'redirect'` keeps the basePath. `'response'` is for files and protocol bodies that are neither JSON nor HTML. A
|
|
24
|
+
`text/html` response is a 500 with HZ053: a page is a `ui.page` with `head.failed`.
|
|
25
|
+
- `input: 'raw'` (POST only) skips parsing and gives the resolver `bytes` (a `Uint8Array`), for signed webhooks.
|
|
26
|
+
- Headers and the raw request: the resolver's context has `request` (a web `Request`). A bearer token:
|
|
27
|
+
`implement(postItem, (input, { request, fail }) => request.headers.get('authorization') === `Bearer ${token}` ? … : fail('Unauthorized', {}))`
|
|
28
|
+
with `errors: { Unauthorized: z.object({}) }, failed: { Unauthorized: 401 }`. Try it from the DevTools API drawer
|
|
29
|
+
(Endpoints: path, body and your own headers) or `curl`. Queries and mutations read no headers: identity is the session.
|
|
30
|
+
- `setSession(value)` works on GET too (auth callbacks); OIDC form_post callbacks use `output: 'redirect'`.
|
|
31
|
+
- `invalidates: (input) => [itemsTag()]` refreshes like a mutation's when the endpoint succeeds. A GET endpoint with
|
|
32
|
+
`invalidates` is HZ062 (warning).
|
|
33
|
+
- A GET endpoint link takes flat scalar input (HZ035). Another feature links to an endpoint only when it is in
|
|
34
|
+
`exports` and imported (HZ006).
|
|
35
|
+
- HZ046 for a path comes with a patch. Responses carry `nosniff` and a referrer policy. Cross-site browser POSTs are
|
|
36
|
+
rejected; server-to-server calls (no `Origin`) are accepted.
|
|
37
|
+
- **What an endpoint cannot do yet:** methods are GET and POST; paths are static (a variable part goes in the
|
|
38
|
+
input: `POST /api/bookings/cancel` with `{ id }`); a success answers 200; an error answers its status with
|
|
39
|
+
`{ error, ...data }` (nested objects are fine, `error` is reserved); there are no custom response headers.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
project({
|
|
5
|
+
env: {
|
|
6
|
+
files: ['.env', '.env.local'], // read by the CLI; a later file wins, the shell wins over all
|
|
7
|
+
server: z.object({ DB_URL: z.string(), API_INTERNAL: z.string().url().optional() }),
|
|
8
|
+
public: z.object({ SUPPORT_EMAIL: z.string().email(), API_URL: z.string().url() }),
|
|
9
|
+
internal: { API_URL: 'API_INTERNAL' }, // on the server, 'either' effects call the internal URL
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
```
|
|
13
|
+
- **Parsed at startup:** a missing value stops startup.
|
|
14
|
+
- **Who reads what:** resolvers `ctx.env` (the `server` variables only); views `ui.env(PublicEnv).SUPPORT_EMAIL`;
|
|
15
|
+
fetch.ts `env` (public); machines cannot read env (HZ041). A server resolver that needs a URL the browser also
|
|
16
|
+
uses declares it in `server` too.
|
|
17
|
+
- **Public values are sent to the browser.** Secrets go in `server`; a public name that looks secret (`…_SECRET`,
|
|
18
|
+
`…_TOKEN`, `…_KEY`) is HZ084.
|
|
19
|
+
- Keep `.env` and `.env.local` out of git.
|
|
20
|
+
|
|
21
|
+
<!-- more -->
|
|
22
|
+
|
|
23
|
+
- **Parsing:** defaults and `z.coerce` apply; a variable set to the empty string counts as unset.
|
|
24
|
+
- **Public values** reach page payloads and a static export (written into the pages at export time). Rename a
|
|
25
|
+
secret-looking name `PUBLIC_…` only if it is meant to be public.
|
|
26
|
+
- **`internal`:** for an API the browser reaches at its public URL and the server reaches inside the network.
|
|
27
|
+
- fetch.ts keeps reading `env.API_URL`;
|
|
28
|
+
- on the server it gets `API_INTERNAL` when that is set, and the public value otherwise;
|
|
29
|
+
- the browser and CSP `connect` only ever see the public one;
|
|
30
|
+
- a mapping to undeclared variables is HZ085;
|
|
31
|
+
- it applies to fetch.ts only. A `runs: 'server'` resolver reads its own `server` variables:
|
|
32
|
+
`ctx.env.API_INTERNAL ?? ctx.env.API_URL` with both declared in `server`.
|
|
33
|
+
- **`files`** are read by `hozu dev`, `serve`, `check`, `get`, `call`, `browse`, `build` and `env`. On the edge
|
|
34
|
+
(`createHandler`) and on hosting platforms, set the variables in the platform.
|
|
35
|
+
- The files are read after `hozu.config.ts` is imported: a value the config itself reads at import time (rare)
|
|
36
|
+
comes from the shell. Resolvers, views and fetch.ts read the parsed env and see the files.
|
|
37
|
+
- **`npx hozu env`** lists every variable: its side, whether it is required, its default, whether it is set now,
|
|
38
|
+
and its internal mapping. `--example` writes `.env.example`.
|
|
39
|
+
- **Reserved by Hozu:**
|
|
40
|
+
- `PORT` (hozu dev also uses `PORT + 1`);
|
|
41
|
+
- `HOST`;
|
|
42
|
+
- `SESSION_SECRET` (required in production with sessions);
|
|
43
|
+
- `NODE_ENV`;
|
|
44
|
+
- `HOZU_TRANSFORM_CACHE=0`.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# A new feature: the files and a complete example
|
|
2
|
+
|
|
3
|
+
Start with `npx hozu add feature <name> --page / --with auth,detail,toggle,filter,remove` and edit the texts it
|
|
4
|
+
lists. It writes the files below and the first `hozu.lock.json`.
|
|
5
|
+
|
|
6
|
+
Files: `features/<name>/model.ts`, `views.ts`, `feature.ts`; resolvers in `app.ts`. Relative imports end in
|
|
7
|
+
`.ts`. Callbacks are ordinary TypeScript, but methods on data (`.map`…) are not: use `ui.each` and a `fn()`.
|
|
8
|
+
|
|
9
|
+
## A feature in one screen
|
|
10
|
+
```ts
|
|
11
|
+
// model.ts
|
|
12
|
+
export const Item = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
13
|
+
export const Add = event({ payload: z.object({ title: z.string() }) })
|
|
14
|
+
export const itemsTag = tag({ param: null })
|
|
15
|
+
export const listItems = query({ input: z.object({}), output: z.array(Item), scope: 'public',
|
|
16
|
+
freshness: 'static', tags: () => [itemsTag()], runs: 'server' }) // resolvers in app.ts
|
|
17
|
+
export const addItem = mutation({ input: z.object({ title: z.string().min(2, 'Too short') }), output: Item,
|
|
18
|
+
errors: { Duplicate: z.object({ title: z.string() }) }, invalidates: () => [itemsTag()], runs: 'server',
|
|
19
|
+
access: 'anyone' }) // who may run it: hozu docs auth
|
|
20
|
+
export const items = machine({
|
|
21
|
+
context: z.object({ draft: z.string(), error: z.string().nullable() }),
|
|
22
|
+
initialContext: { draft: '', error: null },
|
|
23
|
+
initial: 'idle',
|
|
24
|
+
states: ({ ctx }) => ({
|
|
25
|
+
idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title; ctx.error = null } })] },
|
|
26
|
+
adding: {
|
|
27
|
+
invoke: invoke(addItem, {
|
|
28
|
+
input: { title: ctx.draft },
|
|
29
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
30
|
+
failed: {
|
|
31
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
|
|
32
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
// views.ts
|
|
39
|
+
export const Board = ui.view({
|
|
40
|
+
machine: items,
|
|
41
|
+
render: ({ ctx, when }) =>
|
|
42
|
+
ui.main({ class: 'mx-auto max-w-xl' }, [
|
|
43
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
|
|
44
|
+
ui.input({ name: 'title', required: true, value: ctx.draft }),
|
|
45
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
46
|
+
]),
|
|
47
|
+
ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
|
|
48
|
+
when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
|
|
49
|
+
ui.query(listItems, {}, {
|
|
50
|
+
ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
|
|
51
|
+
failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
|
|
52
|
+
}),
|
|
53
|
+
]),
|
|
54
|
+
})
|
|
55
|
+
// feature.ts
|
|
56
|
+
import * as model from './model.ts'
|
|
57
|
+
import * as views from './views.ts'
|
|
58
|
+
export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
|
|
59
|
+
```
|
|
60
|
+
Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
|
|
61
|
+
`implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
|
|
62
|
+
|
|
63
|
+
<!-- more -->
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
- A feature is **declarations**: events, queries / mutations (the only side effects), one machine, views,
|
|
67
|
+
contracts. Builders record them as data (an IR) that is validated, then rendered on the server. Only views bound
|
|
68
|
+
to the machine ship JS.
|
|
69
|
+
- **Callbacks are ordinary TypeScript** (`render`, `guard`, `assign`, `navigate`, `ui.each` / `ui.query`
|
|
70
|
+
callbacks): `===`, `!==`, `<`, `&&`, `||`, `!`, `??`, `c ? a : b`, template strings, `+`, `-`, `.length`, and in
|
|
71
|
+
`assign`, `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(v)`, `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
|
|
72
|
+
Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
|
|
73
|
+
- A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
|
|
74
|
+
`done` / `failed` leave it.
|
|
75
|
+
- A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
|
|
76
|
+
`machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
|
|
77
|
+
- Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
|
|
78
|
+
|
|
79
|
+
## Files
|
|
80
|
+
```
|
|
81
|
+
hozu.config.ts project({ schema, app, site, routes, pages, features }) routes.ts route() declarations
|
|
82
|
+
features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
|
|
83
|
+
features/<name>/feature.ts feature({ declarations: [model, views] }) app.ts app({ resolvers })
|
|
84
|
+
ui/kit.ts ui.kit({ id: 'ui', components }) — Button, Input, Field… ui/*.ts one component each
|
|
85
|
+
```
|
|
86
|
+
Relative imports end in `.ts`. The example above uses plain elements so it runs in any app; with a kit the input
|
|
87
|
+
and button are `ui.use(Input, …)` and `ui.use(Button, …)`, as in `example/` (`hozu docs components`).
|
|
88
|
+
- **Sharing with another feature:** the owner lists what it shares, `exports: [listRooms, roomsTag]`, next to
|
|
89
|
+
`declarations`; the user lists the owner, `imports: [bookings]`. Imports go one way (each `feature.ts` imports
|
|
90
|
+
the other's module): when two features need each other's data, one of them owns it and exports it.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Where effects run: runs and fetch.ts
|
|
2
|
+
|
|
3
|
+
Every query and mutation declares `runs` (required, no default): what its implementation needs.
|
|
4
|
+
|
|
5
|
+
| `runs` | The implementation needs | Implemented in |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `'server'` | a database, a server secret, the session | `app.ts` / `features/<name>/server.ts` resolvers |
|
|
8
|
+
| `'browser'` | the visitor's own data in the browser (a list in `localStorage`), or browser credentials (a token, an OIDC library, the API's own cookies) | `features/<name>/fetch.ts` |
|
|
9
|
+
| `'either'` | nothing special: a public API, or your own API with CORS | `features/<name>/fetch.ts` |
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// fetch.ts: one export per effect, under its name; the model import is type-only
|
|
13
|
+
import { implement } from '@hozu/core/fetch'
|
|
14
|
+
import type * as model from './model.ts'
|
|
15
|
+
export const searchRepos = implement<typeof model.searchRepos>(async ({ q }, { fail, signal, env }) => {
|
|
16
|
+
const r = await fetch(`${env.API_URL}/search/repositories?q=${encodeURIComponent(q)}`, { signal })
|
|
17
|
+
return r.ok ? (await r.json()).items : fail('Unavailable', {})
|
|
18
|
+
})
|
|
19
|
+
export const myRepos = implement<typeof model.myRepos>(async (_, { fail, signal }) => {
|
|
20
|
+
const token = localStorage.getItem('gh-token')
|
|
21
|
+
if (!token) return fail('Unauthorized', {})
|
|
22
|
+
const r = await fetch('https://api.github.com/user/repos', { headers: { authorization: `Bearer ${token}` }, signal })
|
|
23
|
+
return r.status === 401 ? fail('Unauthorized', {}) : r.json()
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
- The feature names the module: `feature({ …, fetch: new URL('./fetch.ts', import.meta.url) })`; `app()` needs
|
|
27
|
+
`components: bundleComponents` (HZ045). A missing or extra export is HZ081.
|
|
28
|
+
- Browser-held data (`localStorage`, a token, the API's own cookies) is `scope: 'user'`; `'either'` needs
|
|
29
|
+
`scope: 'public'` (HZ081).
|
|
30
|
+
- fetch.ts runs in the browser: no Node-only imports, no secrets; `env` is the public env only.
|
|
31
|
+
- Every other origin fetch.ts calls goes in `feature({ connect: ['https://api.github.com', { env: 'API_URL' }] })`
|
|
32
|
+
(HZ083); the API must allow the page's origin (CORS), otherwise use `runs: 'server'`.
|
|
33
|
+
|
|
34
|
+
<!-- more -->
|
|
35
|
+
|
|
36
|
+
## The model side
|
|
37
|
+
```ts
|
|
38
|
+
// model.ts
|
|
39
|
+
export const searchRepos = query({ input: z.object({ q: z.string() }), output: Repos,
|
|
40
|
+
errors: { Unavailable: z.object({}) }, scope: 'public', freshness: 'request', runs: 'either' })
|
|
41
|
+
export const myRepos = query({ input: z.object({}), output: Repos, errors: { Unauthorized: z.object({}) },
|
|
42
|
+
scope: 'user', freshness: 'request', tags: () => [reposTag()], runs: 'browser' })
|
|
43
|
+
export const star = mutation({ input: z.object({ repo: z.string() }), output: z.object({}),
|
|
44
|
+
invalidates: () => [reposTag()], runs: 'browser' })
|
|
45
|
+
// feature.ts
|
|
46
|
+
export const repos = feature({ id: 'repos', intent, declarations: [model, views],
|
|
47
|
+
fetch: new URL('./fetch.ts', import.meta.url) })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Details
|
|
51
|
+
- **What runs where:** `'either'` is server-rendered on first paint (data in the HTML, cached per `freshness`), and
|
|
52
|
+
later in-page reads and mutations call the API from the browser, never through the server. `'browser'` renders its
|
|
53
|
+
`pending` branch on the server and reads after hydration. `'server'` always goes through the server.
|
|
54
|
+
- **Checked at the boundary:** inputs and outputs are checked against their schemas in the browser too; a wrong
|
|
55
|
+
output is `Unexpected` with its path, `fail(Name, data)` is the declared error.
|
|
56
|
+
- `env` is the parsed `public` environment; server env and the session never reach fetch.ts.
|
|
57
|
+
- **fetch.ts runs in the browser** (and on the server for `'either'`). A token read in the browser is sent only to
|
|
58
|
+
the API, never to your own server.
|
|
59
|
+
- **CSP:** Hozu adds every `connect` origin to the page's CSP `connect-src` (an `{ env }` entry reads that public env
|
|
60
|
+
variable's URL at startup). An absolute URL in fetch.ts that `connect` does not list is HZ083; a call blocked at
|
|
61
|
+
run time shows in `hozu browse`.
|
|
62
|
+
- The bundle (`bundleComponents`) carries fetch.ts for the browser.
|
|
63
|
+
- **Rules:** HZ081 (a missing or extra export, or `'either'` with user data), HZ082 (a `'browser'` query in a page
|
|
64
|
+
`head` or `entries`; a browser mutation that invalidates a tag a server-cached query reads).
|
|
65
|
+
- **Static host:** pages with only `'browser'` / `'either'` data export completely; `exportStatic` lists in
|
|
66
|
+
`needsServer` the server effects a page would still call.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
- **Submitted values:** read them with `ui.dom.form('name')` / `ui.dom.formAll('name')` (plus literals, context,
|
|
4
|
+
params, search), so a submit before the page has loaded still arrives (HZ036 otherwise). Name every field.
|
|
5
|
+
```ts
|
|
6
|
+
ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
|
|
7
|
+
ui.label({ for: 'title' }, ['Title']),
|
|
8
|
+
ui.input({ id: 'title', name: 'title', required: true, minlength: 2, value: ctx.draft,
|
|
9
|
+
'aria-invalid': ctx.fields.title !== null, 'aria-describedby': 'title-error',
|
|
10
|
+
on: { input: ui.send(Draft, { text: ui.dom.value }) } }),
|
|
11
|
+
ui.select({ name: 'kind', 'aria-label': 'Kind' }, kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k]))),
|
|
12
|
+
ui.button({ type: 'submit' }, ['Add']),
|
|
13
|
+
])
|
|
14
|
+
ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
|
|
15
|
+
```
|
|
16
|
+
- **Field errors:** context `fields: z.object({ title: z.string().nullable() })`, reset on submit
|
|
17
|
+
(`ctx.fields = { title: null }`), and `failed.Invalid: { target: 'idle', assign: (e) => { ctx.fields = e.fields } }`.
|
|
18
|
+
Limits live in the mutation's input schema (`z.string().min(2, '…')`), never in the event payload (HZ061).
|
|
19
|
+
- **Server error:** a declared error sets `ctx.error`; show `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`.
|
|
20
|
+
- **Enum from a select:** fills an enum field only when every literal option value is a member (HZ033).
|
|
21
|
+
|
|
22
|
+
<!-- more -->
|
|
23
|
+
|
|
24
|
+
- **Without JavaScript,** the server runs the same machine for a native post, then redirects or re-renders with the
|
|
25
|
+
result (e.g. a `<select name="kind">` carries the kind).
|
|
26
|
+
- **With the app's kit** (as in `example/`): a `Field` with a `control` slot holds the label, the input and its error.
|
|
27
|
+
```ts
|
|
28
|
+
ui.use(Field, { props: { for: 'title', label: 'Title', error: ctx.fields.title, errorId: 'title-error' },
|
|
29
|
+
slots: { control: ui.use(Input, { props: { id: 'title', name: 'title', value: ctx.draft, required: true,
|
|
30
|
+
invalid: ctx.fields.title !== null, describedby: 'title-error' }, on: { input: ui.send(Draft, { text: ui.dom.value }) } }) } }),
|
|
31
|
+
ui.use(Button, { props: { type: 'submit' } }, ['Add']),
|
|
32
|
+
```
|
|
33
|
+
- **Limit messages:** `z.string().min(2, 'Use at least 2 characters')`.
|
|
34
|
+
- **Clear after success:** bind `value: ctx.draft` and reset it in `done`.
|
|
35
|
+
- **Per-item actions:** wrap each button in its own small form.
|
|
36
|
+
- **Enum source:** `ui.dom.form('kind')` or `ui.dom.value`.
|
|
37
|
+
- **Several values:** `ui.dom.formAll('ids')` is every value of the name in tree order (`[]` when none) for checkbox
|
|
38
|
+
groups, `select multiple` and controls inside `ui.each`, into a list field; `ui.dom.form(name)` is the first (HZ054).
|
|
39
|
+
- **Which button:** give submit buttons `name` and a literal `value` and read `ui.dom.form('action')` in the form's
|
|
40
|
+
submit; JS and no-JS read the same value. Into an enum only when every submit button of the form has that name and
|
|
41
|
+
a member value, else make the field nullable (HZ033). No `on.click` on a submit button (HZ056).
|
|
42
|
+
- **Controls outside the form** (forms cannot nest): `const bulk = ui.formRef()` at module level,
|
|
43
|
+
`ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
|
|
44
|
+
- **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
|
|
45
|
+
`z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
|
|
46
|
+
- **An invalid native post:** a native post whose payload or mutation input fails re-renders with 400 through
|
|
47
|
+
`failed.Invalid`, like the JS submit.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# HTTP
|
|
2
|
+
|
|
3
|
+
Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
|
|
4
|
+
`project({ http: { basePath, trailingSlash, redirects, headers } })` changes that (see --more). There are no
|
|
5
|
+
rewrites: one URL has one owner. Your own HTTP routes: `hozu docs endpoints`.
|
|
6
|
+
|
|
7
|
+
<!-- more -->
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
http: {
|
|
11
|
+
basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
|
|
12
|
+
trailingSlash: 'always', // or 'never'; the other form answers 308
|
|
13
|
+
redirects: { // keyed by the old path; never a path a page owns (HZ037)
|
|
14
|
+
'/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true },
|
|
15
|
+
'/docs': { to: 'https://docs.example.com', permanent: false },
|
|
16
|
+
},
|
|
17
|
+
headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
|
|
18
|
+
},
|
|
19
|
+
```
|
|
20
|
+
Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
|
|
21
|
+
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Languages
|
|
2
|
+
|
|
3
|
+
- **The URL holds the language:** `site: { lang: 'en', locales: ['en', 'de'], … }` keeps `/posts/a` for `en` and
|
|
4
|
+
prefixes the others (`/de/posts/a`). Routes and `ui.link` stay locale-free. No cookie, session field or rewrite.
|
|
5
|
+
- Switch: `ui.a({ href: ui.alternate('de') }, ['Deutsch'])`.
|
|
6
|
+
- `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
|
|
7
|
+
in a listed module; `text.saved({ count })` in views. Every locale needs every key (HZ040).
|
|
8
|
+
- Machines never hold translated text (HZ041): store a code.
|
|
9
|
+
- **Data per language:** `locale` (in every view, and the second argument of `head.input` / `head.render`) goes into
|
|
10
|
+
the query input: `ui.query(listPosts, { locale })`; `head: { input: (params, locale) => ({ slug: params.slug,
|
|
11
|
+
locale }) }`. It is typed `string`: declare the input `z.string()`, or narrow it with `locale as Locale`.
|
|
12
|
+
|
|
13
|
+
<!-- more -->
|
|
14
|
+
|
|
15
|
+
## Details
|
|
16
|
+
- Reload, sign-in and sign-out keep the language, because every link and `navigate` resolves `ui.link` in the
|
|
17
|
+
page's locale.
|
|
18
|
+
- `/en/posts/a` answers 308 `/posts/a`. There is no `Accept-Language` redirect: a new visit to an unprefixed URL
|
|
19
|
+
shows `site.lang`. Contracts expect the default URLs. `<html lang>`, hreflang and the sitemap are derived. A page
|
|
20
|
+
route starting with a locale segment is HZ060.
|
|
21
|
+
- A toggle: `locale === 'en' ? ui.alternate('de') : ui.alternate('en')`.
|
|
22
|
+
- Messages: use `text.title` or `text.saved({ count })` in views and `head.render`; every locale needs the same
|
|
23
|
+
`{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
|
|
24
|
+
- Machines store a code and the view chooses the message.
|
|
25
|
+
- `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
|
|
26
|
+
`ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view.
|
|
27
|
+
- Resolvers have no locale of their own; it reaches them only through the input. The sitemap lists one entry per
|
|
28
|
+
`entries` input in every locale.
|
|
29
|
+
- `ui.format.date` takes an ISO string or a timestamp and formats it in the page's locale, in the time zone of the
|
|
30
|
+
machine that renders it. A date without a time (`'2026-09-12'`) is midnight UTC: pass `{ timeZone: 'UTC' }` so
|
|
31
|
+
every server and browser shows the same day.
|
|
32
|
+
- A form's `Invalid` messages come from the schema in one language: store `invalid: true` (or a code) in context and
|
|
33
|
+
choose the text in the view.
|
|
34
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Machine (one per feature)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
export const m = machine({
|
|
5
|
+
context: z.object({ draft: z.string(), error: z.string().nullable() }),
|
|
6
|
+
initialContext: { draft: '', error: null },
|
|
7
|
+
initial: 'idle',
|
|
8
|
+
states: ({ ctx }) => ({
|
|
9
|
+
idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title } })] },
|
|
10
|
+
adding: {
|
|
11
|
+
invoke: invoke(addItem, {
|
|
12
|
+
input: { title: ctx.draft },
|
|
13
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' } },
|
|
14
|
+
failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } },
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
})
|
|
19
|
+
```
|
|
20
|
+
- Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
|
|
21
|
+
- **assign** writes context: `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(item)`,
|
|
22
|
+
`ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
|
|
23
|
+
- **guard** returns a condition: `on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 })`; the first
|
|
24
|
+
matching guard wins.
|
|
25
|
+
- **invoke** runs a mutation on entry; the state drops events it does not handle. `failed` lists every declared error
|
|
26
|
+
of the mutation plus `Unexpected` (`Invalid` optional, `hozu docs forms`).
|
|
27
|
+
- An `on` without `target` stays: its timers keep running and an `invoke` keeps going. Naming the state enters it
|
|
28
|
+
again (timers restart, `invoke` re-runs): a debounce, or a timer that repeats.
|
|
29
|
+
- `target: 'previous'` (or `done: 'previous'`) returns to the state the machine came from, so a busy state entered
|
|
30
|
+
from two modes (viewing, editing) needs no copy per mode.
|
|
31
|
+
- **refresh** reads the page's queries with those tags again: `on(RefreshNow, { refresh: () => [quotesTag()] })`;
|
|
32
|
+
every 30 s while live: `live: { after: [{ ms: 30_000, target: 'live', refresh: () => [quotesTag()] }] }` (Pause is
|
|
33
|
+
another state). **copy** writes text to the clipboard: `on(CopyLink, { copy: (e) => e.url })` (on an event: the
|
|
34
|
+
browser allows it only right after a click).
|
|
35
|
+
|
|
36
|
+
<!-- more -->
|
|
37
|
+
|
|
38
|
+
The full form: shared transitions, guards, `navigate`, errors, a timer.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
export const m = machine({
|
|
42
|
+
context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
|
|
43
|
+
initialContext: { draft: '', error: null, target: '' },
|
|
44
|
+
initial: 'idle',
|
|
45
|
+
on: ({ ctx }) => [on(Draft, { assign: (e) => { ctx.draft = e.text } })], // shared by every state without invoke
|
|
46
|
+
states: ({ ctx }) => ({
|
|
47
|
+
idle: {
|
|
48
|
+
on: [
|
|
49
|
+
on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
|
|
50
|
+
on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
|
|
51
|
+
on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
adding: { // runs addItem on entry; drops events it does not handle
|
|
55
|
+
invoke: invoke(addItem, {
|
|
56
|
+
input: { title: ctx.draft },
|
|
57
|
+
done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
|
|
58
|
+
failed: { // every declared error + Unexpected (+ optional Invalid)
|
|
59
|
+
Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
|
|
60
|
+
Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
removing: { invoke: invoke(removeItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) },
|
|
65
|
+
flash: { on: [on(Add, { target: 'adding' })], after: [{ ms: 3000, target: 'idle' }] }, // a toast; the page stays usable
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
- **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
|
|
70
|
+
- **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
71
|
+
- **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
|
|
72
|
+
choose between links, write one guarded transition per link (`[{ guard: () => …, navigate: … }, { navigate: … }]`);
|
|
73
|
+
a `?:` inside `navigate` is HZ014.
|
|
74
|
+
- `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
|
|
75
|
+
- **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
|
|
76
|
+
and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
|
|
77
|
+
contract covers every copy.
|
|
78
|
+
- **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
|
|
79
|
+
page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
|
|
80
|
+
per page may seed a machine (HZ048).
|
|
81
|
+
- In an app with `site.locales`, machines never hold
|
|
82
|
+
translated text (HZ041): store a code (`ctx.error = 'duplicate'`) and choose the message in the view. The
|
|
83
|
+
scaffold does this in every app.
|