@hozu/cli 0.21.1 → 0.23.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.
Files changed (72) hide show
  1. package/bin/hozu.js +2 -2
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +10 -7
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/app.d.ts +3 -0
  6. package/dist/commands/app.d.ts.map +1 -1
  7. package/dist/commands/app.js +23 -0
  8. package/dist/commands/app.js.map +1 -1
  9. package/dist/commands/browse-page.d.ts.map +1 -1
  10. package/dist/commands/browse-page.js +6 -1
  11. package/dist/commands/browse-page.js.map +1 -1
  12. package/dist/commands/browse-tab.d.ts +4 -2
  13. package/dist/commands/browse-tab.d.ts.map +1 -1
  14. package/dist/commands/browse-tab.js +36 -5
  15. package/dist/commands/browse-tab.js.map +1 -1
  16. package/dist/commands/browse-world.d.ts +3 -0
  17. package/dist/commands/browse-world.d.ts.map +1 -1
  18. package/dist/commands/browse-world.js +2 -1
  19. package/dist/commands/browse-world.js.map +1 -1
  20. package/dist/commands/browse.d.ts +10 -1
  21. package/dist/commands/browse.d.ts.map +1 -1
  22. package/dist/commands/browse.js +107 -13
  23. package/dist/commands/browse.js.map +1 -1
  24. package/dist/commands/check.d.ts.map +1 -1
  25. package/dist/commands/check.js +6 -1
  26. package/dist/commands/check.js.map +1 -1
  27. package/dist/commands/gen.d.ts +6 -0
  28. package/dist/commands/gen.d.ts.map +1 -0
  29. package/dist/commands/gen.js +56 -0
  30. package/dist/commands/gen.js.map +1 -0
  31. package/dist/commands/request.d.ts +6 -1
  32. package/dist/commands/request.d.ts.map +1 -1
  33. package/dist/commands/request.js +60 -6
  34. package/dist/commands/request.js.map +1 -1
  35. package/dist/commands/serve.d.ts.map +1 -1
  36. package/dist/commands/serve.js +11 -3
  37. package/dist/commands/serve.js.map +1 -1
  38. package/dist/contract.d.ts +50 -3
  39. package/dist/contract.d.ts.map +1 -1
  40. package/dist/gen/go.d.ts +10 -0
  41. package/dist/gen/go.d.ts.map +1 -0
  42. package/dist/gen/go.js +443 -0
  43. package/dist/gen/go.js.map +1 -0
  44. package/dist/main.d.ts +1 -0
  45. package/dist/main.d.ts.map +1 -1
  46. package/dist/main.js +28 -2
  47. package/dist/main.js.map +1 -1
  48. package/dist/migrate/steps.d.ts.map +1 -1
  49. package/dist/migrate/steps.js +24 -0
  50. package/dist/migrate/steps.js.map +1 -1
  51. package/dist/remote.d.ts +15 -0
  52. package/dist/remote.d.ts.map +1 -0
  53. package/dist/remote.js +66 -0
  54. package/dist/remote.js.map +1 -0
  55. package/package.json +8 -8
  56. package/schema/browse.schema.json +77 -5
  57. package/schema/check.schema.json +2 -1
  58. package/schema/gen.schema.json +78 -0
  59. package/schema/inspect.schema.json +20 -0
  60. package/schema/migrate.schema.json +2 -1
  61. package/schema/render.schema.json +2 -1
  62. package/schema/request.schema.json +35 -1
  63. package/skill/SKILL.md +2 -2
  64. package/skill/example/features/bookmarks/views.ts +1 -2
  65. package/skill/topics/data.md +77 -0
  66. package/skill/topics/diagnostics.md +1 -0
  67. package/skill/topics/http.md +2 -1
  68. package/skill/topics/machine.md +4 -5
  69. package/skill/topics/pages.md +1 -1
  70. package/skill/topics/recipes.md +23 -0
  71. package/skill/topics/testing.md +10 -4
  72. package/skill/topics/views.md +15 -3
@@ -75,9 +75,8 @@ export const Board = ui.view({
75
75
  ui.a(
76
76
  {
77
77
  href: ui.link(home, null, { show: s.value }),
78
- 'aria-current': search.show === s.value,
79
78
  class:
80
- 'rounded-full border px-3 py-1 aria-[current=true]:bg-indigo-600 aria-[current=true]:text-white',
79
+ 'rounded-full border px-3 py-1 aria-[current=page]:bg-indigo-600 aria-[current=page]:text-white',
81
80
  },
82
81
  [s.label],
83
82
  ),
@@ -50,6 +50,7 @@ export default app({ resolvers: resolvers(project, (implement) => [
50
50
  shows it, also from the browser. `'live'` is for data your own mutations change. A refresh the visitor controls
51
51
  (a button, Pause / Resume) is `refresh: () => [tag()]` on a machine transition (`hozu docs machine`).
52
52
  - Call a `fn` from views or machines: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
53
+ - **A database** (pool, migrations, numeric ids): see --more.
53
54
 
54
55
  <!-- more -->
55
56
 
@@ -73,3 +74,79 @@ export default app({ resolvers: resolvers(project, (implement) => [
73
74
  defaults and transforms applied.
74
75
  - Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
75
76
  `fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
77
+
78
+ ## Resolvers in another language (Go)
79
+ Only when the person asks for it or the service already exists in Go: TypeScript resolvers are the default and need
80
+ no second process. `remote()` sends server effects to a service over HTTP; everything else (access, caching, tags,
81
+ the output schema check) stays in the Hozu server, so a wrong answer is `Unexpected`, never a wrong page.
82
+ ```ts
83
+ import { remote, resolvers } from '@hozu/data'
84
+ export default app({
85
+ resolvers: resolvers(project, (implement) => [
86
+ implement(me, (_, { session }) => ({ name: session?.user ?? '' })),
87
+ ...remote(
88
+ {
89
+ url: { env: 'NOTES_SERVICE_URL' },
90
+ secret: { env: 'NOTES_SERVICE_SECRET' },
91
+ contract: new URL('./service/hozu/contract.go', import.meta.url),
92
+ },
93
+ [listNotes, addNote],
94
+ ),
95
+ ]),
96
+ })
97
+ ```
98
+ - The loop: change the declaration in TypeScript → `npx hozu gen` (writes the contract: types, the `Resolvers`
99
+ interface, `Handler`) → implement the interface until `go test ./...` passes → restart the service → `hozu check`.
100
+ A contract older than the declarations is HZ093 naming the changed effects; each effect has its own fingerprint, so
101
+ the service answers 409 only to calls of an effect that changed.
102
+ - In Go: return a declared error as the error value (`hozu.NotesAddNoteDuplicate{Text: t}`), `hozu.Invalid{…}` for
103
+ input problems; `ctx.Session` is nil when signed out; `ctx.SetSession(…)` / `ctx.SignOut()` in mutations. Public
104
+ queries never receive the session. `ctx.File(token)` reads an upload, `ctx.Header` an endpoint's request headers
105
+ (no cookie), `ctx.Preview` preview mode. The secret is required (16+ characters, the same value on both sides,
106
+ HZ093): the service trusts the session it is sent, so serve `hozu.Handler(r, hozu.Options{Secret: …})` on a private
107
+ address (it refuses to start without one).
108
+ - Any other Go error answers 500 with its first line, which reaches `onError` and `Unexpected` as a thrown TypeScript
109
+ error does, with the call's id (`x-hozu-call`) that the service's log line names
110
+ (`hozu: notes.listNotes (call 3fa2c1d0): …`). A service that is not running is `no service answers at <url>`.
111
+ - `.meta({ title: 'Note' })` on a schema makes it one Go type wherever it appears; `z.int()` is `int64`, a plain
112
+ number `float64` (`hozu gen` notes number fields named like ids or counts); a string `z.enum` is a named type with
113
+ one constant per member (`hozu.OrderStatusPending`, named by its title or its field). Only `runs: 'server'` effects
114
+ and JSON endpoints can be remote (HZ093).
115
+ - [`examples/notes-go`](https://github.com/olevatorr/Hozu/tree/main/examples/notes-go) is the reference: the notes
116
+ app with every resolver in Go. Its `service/main.go` is all a service needs besides the resolvers (`go.mod` is
117
+ yours; the contract's folder is the `hozu` package):
118
+ ```go
119
+ func main() {
120
+ secret := os.Getenv("NOTES_SERVICE_SECRET") // the app's remote() secret, 16+ characters
121
+ addr := os.Getenv("NOTES_SERVICE_ADDR") // the app's NOTES_SERVICE_URL is http://<addr>/effect
122
+ mux := http.NewServeMux()
123
+ mux.Handle("/effect", hozu.Handler(newResolvers(), hozu.Options{Secret: secret}))
124
+ server := &http.Server{Addr: addr, Handler: mux}
125
+ go func() {
126
+ if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
127
+ log.Fatal(err)
128
+ }
129
+ }()
130
+ stop, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
131
+ defer cancel()
132
+ <-stop.Done() // finish the calls in flight, then exit
133
+ ctx, done := context.WithTimeout(context.Background(), 5*time.Second)
134
+ defer done()
135
+ server.Shutdown(ctx)
136
+ }
137
+ ```
138
+
139
+ ## A database
140
+ - One pool per process, made in `app.ts`; close it in `app({ dispose: () => pool.end() })` so `hozu get`, `call` and
141
+ `browse` exit and `hozu serve` stops cleanly. Transactions belong in one mutation resolver (`BEGIN` … `COMMIT`).
142
+ - Migrations and seed data are scripts in `package.json` (`"db:migrate": "node db/migrate.ts"`), idempotent, run
143
+ before `npm start`; the URL and password are server env (`project({ env: { server } })`, `hozu docs env`).
144
+ - Route params and form fields are strings: a numeric id is `z.coerce.number()` in the query or mutation input
145
+ (a query input that fails its schema is a program error, reported to `onError` with the field).
146
+ - Data the whole staff shares but only staff may read is `scope: 'user'` with `access` (it is never cached across
147
+ requests). Share one rule: `const staffOnly = part(({ session }) => session.role !== 'editor')`, then
148
+ `access: { allow: staffOnly }` on each effect.
149
+ - A resolver may answer `fail('Forbidden', { message })` (a row deleted meanwhile, a check `access` cannot make);
150
+ with `access: 'signedIn'` its `session` is never null.
151
+ - Another app writing the same database: give the reading app a signed `endpoint` that `invalidates` the tags, and
152
+ call it after a write (`hozu docs endpoints`).
@@ -97,3 +97,4 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
97
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
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
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) |
100
+ | HZ093 | a `remote()` resolver that cannot answer: its generated contract is missing or stale, or it lists an effect the browser runs or a non-JSON endpoint | run `hozu gen` and rebuild the service; implement browser-run effects in fetch.ts and non-JSON endpoints in TypeScript |
@@ -18,4 +18,5 @@ http: {
18
18
  },
19
19
  ```
20
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.
21
+ A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { img: ['https://…'] }` adds sources
22
+ (`script style img font connect frame media`).
@@ -68,8 +68,7 @@ export const m = machine({
68
68
  }),
69
69
  })
70
70
  ```
71
- - A machine the next page shows too keeps its state across the page change (calm states only; fields the address
72
- seeds come from the address).
71
+ - Calm state follows the visitor to a page that shows the same view, and back to the same address.
73
72
  - **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
74
73
  - **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
75
74
  - **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
@@ -79,9 +78,9 @@ export const m = machine({
79
78
  - **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
80
79
  and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
81
80
  contract covers every copy.
82
- - **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
83
- page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
84
- per page may seed a machine (HZ048).
81
+ - **Start from the URL or server data:** a view with a `route` may declare
82
+ `seed: ({ params, search, query }) => ({ q: search.q, email: query(me, {}).email })` (server render, hydration and
83
+ no-JS posts alike; a failed query leaves `initialContext`). One view per page may seed a machine (HZ048).
85
84
  - In an app with `site.locales`, machines never hold
86
85
  translated text (HZ041): store a code (`ctx.error = 'duplicate'`) and choose the message in the view. The
87
86
  scaffold does this in every app.
@@ -11,7 +11,7 @@ export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.arr
11
11
  - **Pages** go in `project({ routes: { home, itemPage }, pages: [...] })` (the whole config: see --more):
12
12
  `ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } })`.
13
13
  - **Head from a query:** `head: { query: getItem, input: (params, locale) => ({ id: params.id }), render: (item) => ({ title:
14
- item.title }), failed: { NotFound: 404 } }`. `failed` maps every declared error of the query (HZ051) to a route
14
+ item.title }), failed: { NotFound: 404 } }`. Both also get `search`. `failed` maps every declared error of the query (HZ051) to a route
15
15
  without params (303) or to `403`, `404` or `410`.
16
16
  - `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image`, `published`, `noindex`;
17
17
  any other is HZ014 (Open Graph, `twitter:card` and the JSON-LD are derived from these).
@@ -81,3 +81,26 @@ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts
81
81
  - Following the system needs no code: Tailwind's `dark:` classes (`bg-white dark:bg-slate-900`).
82
82
  - A switch the visitor chooses: a client component (`hozu docs components`) puts `dark` on `<html>` and keeps the
83
83
  choice in `localStorage`; `app.css` adds `@custom-variant dark (&:where(.dark, .dark *));`.
84
+
85
+ ## A shell shared by many pages (a back office)
86
+ Pages are config, so a helper is the layout:
87
+ ```ts
88
+ const staffHead = { query: me, render: (m) => ({ title: `${m.name} · Admin` }), failed: { Forbidden: signIn } }
89
+ const staff = (route, View) => ui.page(route, { views: [Sidebar, View], head: staffHead })
90
+ export default project({ /* … */ pages: [staff(orders, OrderList), staff(orderDetail, OrderPage), …] })
91
+ ```
92
+ The sidebar's links mark the page shown with `aria-current` by themselves (`aria-[current]:font-bold`).
93
+
94
+ ## Screens with different state
95
+ One machine per feature: an order list (filters, selection) and an order page (shipping, refund) are two features,
96
+ `orders` and `order`, sharing declarations through `exports`. Each machine stays small and its contracts few.
97
+
98
+ ## A multi-step checkout that also works without JavaScript
99
+ Each step is a state and each step's form posts only its own fields: after a native post the server renders the next
100
+ step, and every form on that page carries the machine's state in a signed hidden field, so the next post continues
101
+ from it (going back to edit a step too). Prefill from the member with
102
+ `seed: ({ query }) => ({ email: query(me, {}).email })`.
103
+
104
+ ## A notice after saving
105
+ A `notice` context field set in `done` and cleared by `after: [{ ms: 4000, target: 'idle' }]` on a `saved` state;
106
+ the view shows `ctx.notice !== null && ui.p({ role: 'status' }, [ctx.notice])`. There is no global toast store.
@@ -7,8 +7,8 @@
7
7
  `hozu call api.who --input '{"room":"a"}' --header 'Authorization: Bearer t'` (a POST needs `--write`).
8
8
  - **Drive the app in a real browser, still without a server:**
9
9
  `hozu browse / --session '{"user":"ada"}' --do 'fill New note=Milk' --do 'press Enter' --do 'click Pin in "Milk"'`.
10
- - Steps: `fill <label>=<value>`, `select <label>=<option>`, `check` / `uncheck <label>`, `click <name>`,
11
- `submit "<form>"`, `press <key>`, `wait <ms>`, `goto <path>`, `post <path> a=1&b=2`,
10
+ - Steps: `fill <label>=<value>` (`\n`, `\t` work), `select <label>=<option>`, `check` / `uncheck <label>`,
11
+ `click <name>`, `submit "<form>"`, `press <key>`, `wait <ms>`, `goto <path>`, `post <path> a=1&b=2`,
12
12
  `remember <name> from url|<selector> [@attr]` (later steps read `$name`); a target may end with `in "<text>"`
13
13
  (for fill and select, before or after `=value`).
14
14
  - Labels are what `hozu get <page> --forms` lists; a missing one prints `Did you mean "…"?`. One `--do` may hold
@@ -29,8 +29,12 @@
29
29
 
30
30
  <!-- more -->
31
31
 
32
- - **A calm page:** a step that rebuilds elements unchanged says `N elements rebuilt unchanged (a flash)` (a control
33
- hidden while busy: disable it instead), and layout that moves without input says `layout shift X`. Both are
32
+ - **Server errors:** what the app's `onError` receives (a resolver that threw, an invalid input) is listed under the
33
+ step or the `get` request that caused it, `server error: <message> (<feature.effect>)`; `--json` `serverErrors`.
34
+ - **A calm page:** a step that rebuilds elements unchanged says `N elements rebuilt unchanged (a flash: main > form >
35
+ button[type=submit])`, naming up to five (`--json` `flashes.elements` has all; a control hidden while busy: disable
36
+ it instead). Equal means tag, class, text, `name`, `id`, `href`, `src`, `type` and parent path; a node that moved is
37
+ no flash. Layout that moves without input says `layout shift X`. Both are
34
38
  problems to fix; a calm step prints neither. An address changed with `replace` stays `in place`.
35
39
  - **`hozu get`** prints the status, redirect, `set-cookie` attributes (`HttpOnly`, `SameSite`), title, alerts and
36
40
  visible text.
@@ -65,6 +69,8 @@
65
69
  navigation prints `→ <path>` and the new page's lines; a live update on another actor's page prints under the step
66
70
  (`bob: + Milk`).
67
71
  - `≠ DIFFERS` marks a step where both modes made a request and the resulting text differs: a no-JS/JS parity bug.
72
+ It names the differing words (`≠ DIFFERS (on vs off): "#1307" vs "#1306"`): the two modes write twice to the same
73
+ data, so a new row per mode (an order number, a count) differs without a bug.
68
74
  - Errors: uncaught exceptions, `console.error`s, CSP violations and failed requests, each with the page, the
69
75
  resource type and the mode. A 400 re-render of an invalid native post is not an error, and a page answering
70
76
  401, 403, 404 or 410 is the step's status (`→ /notes/n1 (403)`), so an access check exits 0.
@@ -23,10 +23,10 @@ export const Board = ui.view({
23
23
  - **Events:** `on: { click: ui.send(Event, payload) }`; payload fields are literals, data, `ui.dom.value`,
24
24
  `ui.dom.form('name')` (submit; `hozu docs forms`). A control that only sets a context field:
25
25
  `on: { click: ui.set(ctx.open, !ctx.open) }`, `on: { input: ui.set(ctx.q, ui.dom.value) }` (no event to declare).
26
- - **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032).
26
+ - **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). Menus,
27
+ dialogs, plurals: --more.
27
28
  - **Data:** `ui.query(listItems, input, { ready: (items) => …, failed: { NotFound: () => …, Unexpected: () => … } })`;
28
- `failed` lists every declared error plus `Unexpected`. When the input changes, the rows stay (`aria-busy` on the
29
- parent) and update by key; `pending` shows only before the first answer.
29
+ `failed` lists every declared error plus `Unexpected`.
30
30
  - **Shared UI** (buttons, inputs, fields): `ui.use(Button, { variant, props, on }, ['Save'])` of a kit component
31
31
  (`hozu docs components`).
32
32
 
@@ -63,3 +63,15 @@ export const Board = ui.view({
63
63
  (`hozu docs components`; `examples/showcase` has a Chart.js one).
64
64
  - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
65
65
  import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
66
+
67
+ ## Menus, dialogs, counting
68
+ - When a query's input changes, the rows stay (`aria-busy` on the parent) and update by key; `pending` shows only
69
+ before the first answer.
70
+ - A link to the page shown gets `aria-current="page"`, a link to a section above it (`/orders` on `/orders/7`)
71
+ `"true"`; the same path with another search (a next page) gets nothing. An `aria-current` you set wins.
72
+ - `ui.dialog({ open: is(['editing']), on: { close: ui.send(Cancel, {}) } }, [...])` opens as a modal and closes with
73
+ the machine; Escape sends `close`. It needs JavaScript: a dialog that must open without it uses the native
74
+ `commandfor` button (the short form) and closes when the data that shows it changes.
75
+ - `ui.format.plural(n, { one: '# item', other: '# items' })` picks the case for the page's language (`=0` works).
76
+ - `null` and `false` render nothing, also inside a constant list:
77
+ `ui.ul({}, [...kinds.map((k) => (k === 'draft' ? null : ui.li({}, [k])))])`.