weft 0.1.0 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -28
  3. data/README.md +46 -23
  4. data/docs/app-patterns.md +8 -7
  5. data/docs/arbre.md +49 -18
  6. data/docs/configuration.md +42 -35
  7. data/docs/dsl.md +356 -105
  8. data/docs/error-handling.md +64 -24
  9. data/docs/examples/active-search.md +10 -10
  10. data/docs/examples/browser-dialogs.md +10 -10
  11. data/docs/examples/bulk-update.md +11 -11
  12. data/docs/examples/click-to-edit.md +14 -14
  13. data/docs/examples/click-to-load.md +8 -8
  14. data/docs/examples/delete-row.md +17 -19
  15. data/docs/examples/edit-row.md +17 -14
  16. data/docs/examples/file-upload.md +5 -5
  17. data/docs/examples/infinite-scroll.md +8 -8
  18. data/docs/examples/inline-expansion.md +8 -8
  19. data/docs/examples/inline-validation.md +17 -17
  20. data/docs/examples/lazy-loading.md +8 -8
  21. data/docs/examples/live-ticker.md +1 -1
  22. data/docs/examples/modal-dialog.md +3 -3
  23. data/docs/examples/progress-bar.md +1 -1
  24. data/docs/examples/reset-user-input.md +7 -7
  25. data/docs/examples/tabs.md +4 -4
  26. data/docs/examples/tooltip.md +8 -8
  27. data/docs/examples/updating-other-content.md +9 -9
  28. data/docs/examples/value-select.md +11 -11
  29. data/docs/params.md +112 -0
  30. data/docs/routing.md +13 -13
  31. data/docs/tutorial.md +46 -48
  32. data/lib/weft/action.rb +4 -2
  33. data/lib/weft/autoloading.rb +69 -0
  34. data/lib/weft/component.rb +97 -31
  35. data/lib/weft/configuration.rb +37 -5
  36. data/lib/weft/context/expansion.rb +184 -0
  37. data/lib/weft/context/interception.rb +22 -2
  38. data/lib/weft/context/modifiers.rb +78 -0
  39. data/lib/weft/context/traversal.rb +80 -0
  40. data/lib/weft/context/wiring.rb +85 -0
  41. data/lib/weft/context.rb +70 -164
  42. data/lib/weft/defaults/error_component.rb +57 -21
  43. data/lib/weft/defaults/error_page.rb +12 -10
  44. data/lib/weft/defaults/not_found_component.rb +14 -12
  45. data/lib/weft/defaults/not_found_page.rb +9 -8
  46. data/lib/weft/dsl/actions.rb +9 -9
  47. data/lib/weft/dsl/inclusions.rb +48 -11
  48. data/lib/weft/dsl/params.rb +265 -0
  49. data/lib/weft/dsl/recoveries.rb +36 -6
  50. data/lib/weft/dsl/sandbox.rb +26 -0
  51. data/lib/weft/dsl/triggers.rb +28 -8
  52. data/lib/weft/dsl/updates.rb +31 -8
  53. data/lib/weft/error.rb +12 -1
  54. data/lib/weft/page/assets.rb +222 -0
  55. data/lib/weft/page/head.rb +87 -0
  56. data/lib/weft/page.rb +55 -239
  57. data/lib/weft/params/assembly.rb +170 -0
  58. data/lib/weft/params.rb +138 -0
  59. data/lib/weft/presets.rb +96 -0
  60. data/lib/weft/redirect.rb +7 -7
  61. data/lib/weft/registry/eligibility.rb +5 -19
  62. data/lib/weft/registry.rb +58 -18
  63. data/lib/weft/resolver.rb +48 -20
  64. data/lib/weft/router/actions.rb +106 -22
  65. data/lib/weft/router/errors.rb +223 -83
  66. data/lib/weft/router/oob_includes.rb +202 -17
  67. data/lib/weft/router/streaming.rb +86 -20
  68. data/lib/weft/router.rb +33 -25
  69. data/lib/weft/version.rb +1 -1
  70. data/lib/weft.rb +37 -24
  71. metadata +32 -8
  72. data/lib/weft/attributes.rb +0 -65
  73. data/lib/weft/dsl/attributes.rb +0 -43
  74. data/lib/weft/shorthands.rb +0 -57
@@ -16,12 +16,12 @@ CAR_MODELS = {
16
16
  class ModelSelect < Weft::Component
17
17
  builder_method :model_select
18
18
 
19
- attribute :make, default: "audi"
19
+ param :make, default: "audi"
20
20
 
21
21
  def build(attributes = {})
22
22
  super
23
23
  set_attribute :name, "model"
24
- CAR_MODELS.fetch(attrs.make).each do |model|
24
+ CAR_MODELS.fetch(params.make).each do |model|
25
25
  option model, value: model.downcase
26
26
  end
27
27
  end
@@ -41,7 +41,7 @@ class CarsPage < Weft::Page
41
41
  div do
42
42
  label "Make ", for: "make"
43
43
  select name: "make", id: "make",
44
- loads: ModelSelect, trigger: "change", swap: :fill, target: "#models" do
44
+ loads: ModelSelect, trigger: :change, swap: :fill, target: "#models" do
45
45
  option "Audi", value: "audi"
46
46
  option "Toyota", value: "toyota"
47
47
  option "BMW", value: "bmw"
@@ -50,7 +50,7 @@ class CarsPage < Weft::Page
50
50
  div do
51
51
  label "Model "
52
52
  span id: "models" do
53
- model_select(make: "audi")
53
+ model_select
54
54
  end
55
55
  end
56
56
  end
@@ -61,13 +61,13 @@ end
61
61
 
62
62
  ## How it works
63
63
 
64
- **The select's own value is the parameter.** The [`loads:`](../dsl.md#loads) kwarg generates a GET to `ModelSelect`'s route with *no* query string — and htmx completes it at request time: per [its parameter rules](https://htmx.org/docs/#parameters), the element that causes a request includes its own `name=value`. Changing the select to Toyota therefore sends `GET /_components/model_select?make=toyota`, and the component's declared `make` attribute picks the parameter up on the server (see [Attributes](../dsl.md#attributes)). That inclusion is htmx client-side behavior — you won't see it in any rendered attribute — so if the models ever fail to repopulate, the first thing to check is that the triggering select still has its `name`.
64
+ **The select's own value is the parameter.** The [`loads:`](../dsl.md#loads) kwarg generates a GET to `ModelSelect`'s route with *no* query string — and htmx completes it at request time: per [its parameter rules](https://htmx.org/docs/#parameters), the element that causes a request includes its own `name=value`. Changing the select to Toyota therefore sends `GET /_components/model_select?make=toyota`, and the component's declared `make` param picks the parameter up on the server (see [Params](../dsl.md#params)). That inclusion is htmx client-side behavior — you won't see it in any rendered attribute — so if the models ever fail to repopulate, the first thing to check is that the triggering select still has its `name`.
65
65
 
66
- **Keep the URL clean of `with:`.** Baking `with: { make: ... }` into the URL would freeze the value at render time, fighting the live selection htmx appends. In page markup, simply omitting `with:` does the right thing. Inside a *component's* `build`, though, an omitted `with:` defaults to that component's current attrs — pass an explicit `with: {}` there to keep the live value as the only parameter.
66
+ **Keep the URL clean of `with:`.** Baking `with: { make: ... }` into the URL would freeze the value at render time, fighting the live selection htmx appends. In page markup, simply omitting `with:` does the right thing. Inside a *component's* `build`, though, an omitted `with:` defaults to that component's current params — pass an explicit `with: {}` there to keep the live value as the only parameter.
67
67
 
68
- **The component is the `<select>` itself.** Overriding `tag_name` (an [Arbre-layer move](../arbre.md#inside-build-the-component-contract)) makes the wrapper element a `<select>` rather than the default `<div>`, so the fetched fragment drops into the form as a real form control. Note that its `name` attribute is set inside `build` rather than at the call site: a fragment fetched over the wire is rebuilt from its declared attributes alone, so any wrapper attribute the pattern depends on belongs in `build`. And its DOM id derives from the `make` value, changing with every swap — which is why the make select targets the stable `#models` slot with `swap: :fill` instead of chasing the select by id.
68
+ **The component is the `<select>` itself.** Overriding `tag_name` (an [Arbre-layer move](../arbre.md#inside-build-the-component-contract)) makes the wrapper element a `<select>` rather than the default `<div>`, so the fetched fragment drops into the form as a real form control. Note that its `name` attribute is set inside `build` rather than at the call site: a fragment fetched over the wire is rebuilt from its declared params alone, so any wrapper attribute the pattern depends on belongs in `build`. And its DOM id derives from the `make` value, changing with every swap — which is why the make select targets the stable `#models` slot with `swap: :fill` instead of chasing the select by id.
69
69
 
70
- **`trigger: "change"` is spelled out for clarity.** It's also htmx's default trigger for a `<select>`, which is why the original htmx example omits it; keeping it explicit costs one kwarg and makes the interaction readable at the call site.
70
+ **`trigger: :change` is spelled out for clarity.** It's also htmx's default trigger for a `<select>`, which is why the original htmx example omits it; keeping it explicit costs one kwarg and makes the interaction readable at the call site.
71
71
 
72
72
  ## On the wire
73
73
 
@@ -100,10 +100,10 @@ Choosing Toyota sends `GET /_components/model_select?make=toyota`, which returns
100
100
  </select>
101
101
  ```
102
102
 
103
- — and htmx fills `#models` with it. With no `make` parameter at all, the attribute's default renders the Audi list; parameters the component doesn't declare are simply ignored.
103
+ — and htmx fills `#models` with it. With no `make` parameter at all, the param's default renders the Audi list; parameters the component doesn't declare are simply ignored.
104
104
 
105
105
  ## Related
106
106
 
107
107
  - [Click to Edit](click-to-edit.md) — the same `loads:` machinery swapping whole UI states instead of one control.
108
- - [`loads:`](../dsl.md#loads) and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Attributes](../dsl.md#attributes) for how parameters become `attrs`.
109
- - The [`live_search:` shorthand](../dsl.md#shorthands) is this same fetch-into-a-slot pattern, triggered by typing instead of selecting.
108
+ - [`loads:`](../dsl.md#loads) and [`trigger:`](../dsl.md#trigger) in the DSL reference; [Params](../dsl.md#params) for how parameters become `params`.
109
+ - The [`live_search:` preset](../dsl.md#presets) is this same fetch-into-a-slot pattern, triggered by typing instead of selecting.
data/docs/params.md ADDED
@@ -0,0 +1,112 @@
1
+ # How params flow
2
+
3
+ A Weft component's data — the values it renders from, the record it looks up, the id it needs to refresh itself — all arrive through one object: `params`. This page follows that data from the moment a request lands to the moment a component re-renders itself, and shows where each of the four declarations (`param`, `receives`, `derives`, `defines`) fits. For the precise behavior of each, see [the DSL reference](dsl.md#params); this page is the map, not the legend.
4
+
5
+ The whole model in one breath: **a request's params flow in and down the render tree; each component pulls out the ones it declares; and each component renders carrying enough of its own wire state that a later request — a refresh, an action — can reconstruct it from scratch.**
6
+
7
+ ## A request comes in
8
+
9
+ Every render starts with a request, and a request carries **wire params**: the query string, the path segments, and any form body, all as strings. Weft routes the request to a page (or a standalone component), which resolves its declared `param`s from those wire values — coercing each into its declared `type:`, and filling in defaults where the request said nothing.
10
+
11
+ ```ruby
12
+ class OrderPage < Weft::Page
13
+ self.page_path = "/orders/:order_id"
14
+ param :order_id
15
+ end
16
+ ```
17
+
18
+ A GET to `/orders/42` gives this page `params.order_id == "42"`. Nothing else about the request — session, headers, cookies — is part of `params`; that channel is deliberately just the request's own parameters. (For per-request identity like the current user, see [Application patterns](app-patterns.md#authentication-and-sessions).)
19
+
20
+ ## Params flow down the render tree
21
+
22
+ A page is rarely one component. It embeds others, which embed others. Within a single render, **each component starts from a copy of its nearest ancestor's resolved params** — it sees everything above it in the tree, nothing beside it.
23
+
24
+ ```ruby
25
+ class OrderPage < Weft::Page
26
+ param :order_id
27
+
28
+ def build(attributes = {})
29
+ super
30
+ order_summary # embedded — no arguments
31
+ end
32
+ end
33
+
34
+ class OrderSummary < Weft::Component
35
+ builder_method :order_summary
36
+ # declares no order_id of its own …
37
+
38
+ def build(attributes = {})
39
+ super
40
+ h2 "Order #{params.order_id}" # … yet reads it, inherited from the page
41
+ end
42
+ end
43
+ ```
44
+
45
+ `OrderSummary` never declares `order_id`, but because it renders inside a page that has it, it reads `params.order_id` for free. This is why embedding is so quiet in Weft: a child that needs what its parent already holds just reads it. Declaring the key anyway (`param :order_id`) is often worth it — it documents the dependency and, as the next sections show, lets the child stand on its own — but it isn't required merely to *read* an inherited value.
46
+
47
+ ## Four doors: how a component gets what it needs
48
+
49
+ When a component wants to control a value rather than inherit it, it declares that value — through whichever of the four doors suits the value's nature:
50
+
51
+ - **`param`** — wire state, small enough to travel in a URL: an id, a page number, a filter. Comes from the request, or is inherited from above.
52
+ - **`derives`** — a value the component computes for itself, lazily, the first time it's read.
53
+ - **`defines`** — a static value a subclass pins; sugar over `derives`.
54
+ - **`receives`** — a rich object a caller hands over directly: a record, a built collection, anything that can't ride a query string.
55
+
56
+ A single key can have more than one door, and they resolve in a fixed order — a handed value beats a request overlay (a hash a verb block returned earlier in the request) beats a wire value beats an inherited value beats a derivation beats a default. The [DSL reference](dsl.md#how-the-doors-combine) lays out that precedence and the useful *dual* combinations; the shape to carry away here is that all four doors land in the same `params`, read the same way (`params.name`).
57
+
58
+ Three of the four are open to every verb block too — an action callable, a `transfers`, `includes` or `recovers` block all read the same `param`, `derives` and `defines` a `build` would. Only `receives` is missing there, and it has to be: a hand-off comes from a call site, and a request arriving over the wire has none. When a block needs such a key, give it a second door.
59
+
60
+ ## What a component keeps for itself
61
+
62
+ Here is the pivot that makes the whole system hold together: **only a component's own declared `param`s are serialized.** When Weft renders a component, it bakes that component's wire params into the things that will make the *next* request on its behalf —
63
+
64
+ - its **refresh URL** (`refreshes`) and **stream URL** (`pushes`),
65
+ - the **payload of every action** it declares (`performs`, `transfers`, `dismisses`).
66
+
67
+ The other three doors never serialize: you can't put an `Order` object in a query string, and a derived value can always be re-derived. So what travels forward is exactly the URL-safe wire state the component declared with `param`, and nothing else. Inherited values don't travel either — a child that merely *read* its parent's `order_id` doesn't carry it. A child that needs `order_id` on the next request must declare it.
68
+
69
+ The same discipline applies to a component's HTML attributes. Chrome passed at the call site (`status_card(class: "wide", name: "picker")`) exists only in that in-page render — a wire re-render rebuilds the component from its params alone, and the call-site attributes are gone. An attribute the component *depends on* (a `name` the pattern reads, an ARIA role) belongs inside `build` via `set_attribute`, where every render path reproduces it.
70
+
71
+ ## The round trip: refresh and actions
72
+
73
+ This is the payoff. Because a rendered component carries its own wire params, it can regenerate itself without its parent in the picture:
74
+
75
+ - A **refresh** is a GET to the component's own route, its own params in the query string. Weft routes straight to that component, resolves those params from the wire — the first step again — and re-renders.
76
+ - An **action** is a POST (or DELETE) to the component's route, its params in the payload. The callable runs, then the component re-renders — and the re-render, nested components and OOB companions included, resolves against the *same request wire*, with the callable's returned hash overlaid on top. One universe per request, amended by the verbs that run in it: no matter how rendering flows inside a request (an action re-render, a `transfers` hand-off, an error recovery), a component that reads a wire param keeps reading it, without any outer component relaying it.
77
+
78
+ The callable and the render that follows it are two points on **one chain**, not two independent resolutions. Weft composes the component's params from the wire, hands that to the callable, layers whatever the callable returned on top, and passes the result on to the render — which branches it the same way a child branches its parent's. So a callable reads the same `derives` its `build` does, and a derivation it forces is already a value by the time the render, and the companions riding alongside, read the same key. One lookup, one response.
79
+
80
+ So "render with enough to get where it needs" is literal: whatever a component will need to reconstruct itself on the next request, it must hold as its own `param`s at render time, because that is what gets serialized into the refresh URL and the action payload. A self-refreshing card embedded as `status_card(status: "hot")` keeps refreshing correctly *only* if it declares `param :status` — otherwise the refresh request carries no status and the standalone re-render has nothing to go on.
81
+
82
+ ## Lists, and why `receives` exists
83
+
84
+ Inheritance flows one value down to every descendant — which is exactly wrong for a list, where each row needs a *different* value. Siblings share their parent's bag; they can't each inherit a distinct id. So the parent must **hand** each row its own value, and that is what `receives` is for:
85
+
86
+ ```ruby
87
+ class ContactRow < Weft::Component
88
+ builder_method :contact_row
89
+
90
+ param :contact_id # serialized — lets the row refresh and act on its own
91
+ receives :contact_id # handed — the table gives each row its distinct id
92
+ end
93
+
94
+ class ContactsTable < Weft::Component
95
+ builder_method :contacts_table
96
+
97
+ def build(attributes = {})
98
+ super
99
+ tbody do
100
+ CONTACT_BOOK.each_key { |id| contact_row(contact_id: id) }
101
+ end
102
+ end
103
+ end
104
+ ```
105
+
106
+ The `receives :contact_id` is what consumes the `contact_row(contact_id: id)` hand-off — without it, that kwarg would fall through and render as a stray HTML attribute. The `param :contact_id` alongside it is what lets each row stand on its own: when a row fires its delete action, its id is already serialized into the payload, so the server knows which contact to remove. Handed when embedded, wire-borne when acting — two doors, one key. This *dual* is the backbone of every interactive list; you'll see it in [Delete Row](examples/delete-row.md) and [Edit Row](examples/edit-row.md).
107
+
108
+ ## The shape of it
109
+
110
+ End to end: a request's wire params resolve into the top component and flow down the tree; each component reads what it inherits and declares what it wants to own; at render time each bakes its own wire params into its refresh URL and action payloads; and the next request — refresh or action — arrives carrying exactly what that component needs to do it all again. Data in from the wire, data down the tree, data forward into the next request. That loop is the whole of it.
111
+
112
+ That loop moves *data* down the tree. Once in a while a component needs to reach the other way — *up* the tree, for an ancestor's **identity** rather than its data: a nested pager needs its enclosing panel's route and DOM id to aim a swap at it. That's a separate affordance, [`closest` / `enclosing`](arbre.md#reaching-enclosing-components) — not part of the params flow, but its natural complement. Params come *to* you; identity you reach *for*.
data/docs/routing.md CHANGED
@@ -30,7 +30,7 @@ One prerequisite worth knowing: registration happens when a class is *defined* (
30
30
 
31
31
  ## Component routes
32
32
 
33
- Every routable component is addressable at a GET route that renders it as an HTML fragment — the mechanism behind `refreshes`, `navigate:`, `loads:`, and the shorthands, and equally usable directly (`curl` it; you'll get the component's HTML).
33
+ Every routable component is addressable at a GET route that renders it as an HTML fragment — the mechanism behind `refreshes`, `navigate:`, `loads:`, and the presets, and equally usable directly (`curl` it; you'll get the component's HTML).
34
34
 
35
35
  The path derives from the class name: strip a trailing `Component` if present, snake-case what's left, prefix `/_components/`. Namespaces become path segments.
36
36
 
@@ -42,7 +42,7 @@ The path derives from the class name: strip a trailing `Component` if present, s
42
42
 
43
43
  The suffix-stripping means `OrdersPanel` and `OrdersPanelComponent` are the same route — pick whichever naming style your app prefers, consistently. The `/_components/` prefix keeps the fragment namespace visibly separate from your page URLs; the leading underscore marks it as infrastructure.
44
44
 
45
- Attributes arrive as query parameters (`/_components/orders_panel?status=shipped&page=2`) and resolve through the component's declared schema — undeclared parameters are ignored, and declared ones are type-coerced from their defaults (see [Attributes](dsl.md#attributes)).
45
+ Params arrive as query parameters (`/_components/orders_panel?status=shipped&page=2`) and resolve through the component's declared schema — undeclared parameters are ignored, and declared ones are coerced per their declared `type:` (see [Params](dsl.md#params)).
46
46
 
47
47
  To change the path for one class, set `component_path` on it — a string, or a proc receiving the class:
48
48
 
@@ -69,30 +69,30 @@ A component declaring `pushes` also gets an SSE endpoint at its path plus the st
69
69
 
70
70
  ## Page routes
71
71
 
72
- Pages route as full HTML documents at people-facing URLs — no prefix, no derivation from fragments. A page declares its pattern with `page_path`, Sinatra-style, with `:param` segments mapping to attributes:
72
+ Pages route as full HTML documents at people-facing URLs — no prefix, no derivation from fragments. A page declares its pattern with `page_path`, Sinatra-style, with `:param` segments mapping to params:
73
73
 
74
74
  ```ruby
75
75
  class OrderDetailPage < Weft::Page
76
76
  self.page_path = "/orders/:order_id"
77
- attribute :order_id
77
+ param :order_id
78
78
  end
79
79
  ```
80
80
 
81
- A request for `/orders/42` renders the page with `attrs.order_id == "42"`. Path parameters merge with query and body parameters (path wins on conflicts), and the combined set resolves through the page's attribute schema like any other wire state.
81
+ A request for `/orders/42` renders the page with `params.order_id == "42"`. Path parameters merge with query and body parameters (path wins on conflicts), and the combined set resolves through the page's param schema like any other wire state.
82
82
 
83
- The pattern is bidirectional — it also builds URLs. `Weft.redirect(OrderDetailPage, order_id: 42)` interpolates the attrs into the pattern, and `OrderDetailPage.redirect_url(order_id: 42, highlight: "items")` additionally turns declared-but-not-in-path attrs into a query string (undeclared keys are discarded, never leaked into URLs).
83
+ The pattern is bidirectional — it also builds URLs. `Weft.redirect(OrderDetailPage, order_id: 42)` interpolates the params into the pattern, and `OrderDetailPage.redirect_url(order_id: 42, highlight: "items")` additionally turns declared-but-not-in-path params into a query string (undeclared keys are discarded, never leaked into URLs).
84
84
 
85
85
  Pages without an explicit `page_path` infer one from the class name: demodulized, snake-cased, with a trailing `Page` stripped if present — `DashboardPage` and `Dashboard` both route at `/dashboard`. Two edges of the inference to know:
86
86
 
87
- - A page with **attributes** must declare `page_path` explicitly — a parameterized pattern can't be guessed from a name, so Weft raises with the pattern it suggests rather than inventing one.
87
+ - A page with **params** must declare `page_path` explicitly — a parameterized pattern can't be guessed from a name, so Weft raises with the pattern it suggests rather than inventing one.
88
88
  - A page named such that nothing usable remains after stripping (`Admin::Page`) also raises, with the remediation options spelled out.
89
89
 
90
90
  ## What routes — and what doesn't
91
91
 
92
92
  Registration and routability are separate ideas. *Every* `Weft::Component` and `Weft::Page` subclass registers; whether it gets a route is inferred from what it declares:
93
93
 
94
- - A **component** is routable when it declares interactive behavior: any attribute, action, `refreshes`, or `pushes`. A purely presentational component — just a `build` method — registers but is never served; there's nothing to address it *for*.
95
- - A **page** is routable when it has a usable path: an explicit `page_path`, or a name the default can be derived from (and no attributes, per the edge above).
94
+ - A **component** is routable when it declares interactive behavior: any param, action, `refreshes`, or `pushes`. A purely presentational component — just a `build` method — registers but is never served; there's nothing to address it *for*.
95
+ - A **page** is routable when it has a usable path: an explicit `page_path`, or a name the default can be derived from (and no params, per the edge above).
96
96
 
97
97
  ### `abstract!` and `routable!`
98
98
 
@@ -108,9 +108,9 @@ end
108
108
 
109
109
  ### Routable vs. render target
110
110
 
111
- "Routable" means *addressable at its own GET URL* — and that is orthogonal to being a **render target**. Verbs with transfer semantics (`transfers to:`, `recovers with:`) render their target on the server, inside an in-flight response; the target class needs attributes to render with, but no route of its own. Weft's own default error components work exactly this way: they're `abstract!`, unreachable by URL, and rendered constantly.
111
+ "Routable" means *addressable at its own GET URL* — and that is orthogonal to being a **render target**. Verbs with transfer semantics (`transfers to:`, `recovers with:`) render their target on the server, inside an in-flight response; the target class needs params to render with, but no route of its own. Weft's own default error components work exactly this way: they're `abstract!`, unreachable by URL, and rendered constantly.
112
112
 
113
- Navigation-semantic wiring (`refreshes`, `navigate:`, `loads:`, shorthands) *does* need its target addressable — and here the inference has a gap to watch. Declaring `refreshes` makes a component routable, but being the *target* of another component's `loads:` or shorthand kwarg confers nothing: a target with no attributes and no verbs of its own quietly stays off the route table, and the element wired to load it gets a not-found response at interaction time. Most real targets declare attributes and route on their own; for a purely presentational one, declare `routable!` explicitly. Where you'll reach for `abstract!` is the opposite case: a transfer target that declares attributes (so it can render) but should never be an endpoint — declare it abstract and it carries attributes for rendering while staying off the route table.
113
+ Navigation-semantic wiring (`refreshes`, `navigate:`, `loads:`, presets) *does* need its target addressable — and being the *target* of another component's `loads:` or preset kwarg confers nothing by itself: a target with no params and no verbs of its own stays off the route table. Weft refuses to wire a click that could only 404: a `loads:` or preset kwarg naming a non-routable class raises `Weft::InvalidUsage` at render time, as does a `navigate:` inside a component marked `abstract!`/`dependent!`. Most real targets declare params and route on their own; for a purely presentational one, declare `routable!` explicitly — the error says so. Where you'll reach for `abstract!` is the opposite case: a transfer target that declares params (so it can render) but should never be an endpoint — declare it abstract and it carries params for rendering while staying off the route table, because transfer rendering happens server-side and never fetches.
114
114
 
115
115
  ## Collision detection
116
116
 
@@ -126,6 +126,6 @@ The same validation rejects malformed paths (anything that isn't a string beginn
126
126
 
127
127
  ## Code reloading
128
128
 
129
- Development-mode reloaders redefine constants, which would strand the *old* class object in Weft's registry — and a stale twin at the same path would read as a route collision. Weft prunes superseded registrations automatically: at route-resolution time it drops any registered class whose name no longer resolves to that same class object. The sweep is memoized per registry generation, so production pays it once, ever.
129
+ Development-mode reloaders redefine constants, which would strand the *old* class object in Weft's registry — a stale twin at the same path that reads as a route collision, or worse, a deleted class whose route keeps serving. The registry's answer is eviction: `Weft.registry.evict(klass)` removes a class and re-arms route validation, so the fresh definition (or nothing, if the file is gone) takes over cleanly.
130
130
 
131
- This works with any reloading setup — [`auto_reload`](configuration.md#auto_reload), or your own Zeitwerk `reload` hook. `Weft.registry.clear` is the explicit full-reset primitive if your integration wants to rebuild registration from scratch.
131
+ With [`Weft.configure_autoloading`](configuration.md#autoloading-weftconfigure_autoloading) and `reload: true`, this is wired for you — Zeitwerk announces each constant it unloads and Weft evicts it on the spot. If you drive your own reloader, call `evict` from its unload hook, or use `Weft.registry.clear` to rebuild registration from scratch on each reload. A collision error naming two classes with the same name is the tell that a reload happened without eviction.
data/docs/tutorial.md CHANGED
@@ -68,23 +68,16 @@ require "weft"
68
68
 
69
69
  APP_ROOT = File.expand_path("..", __dir__)
70
70
 
71
- # Load the application: data first, then components, then pages
72
- # (pages compose components). Within each directory, files load
73
- # alphabetically.
74
- %w[data components pages].each do |dir|
75
- Dir[File.join(APP_ROOT, "app", dir, "*.rb")].sort.each { |file| require file }
76
- end
77
-
78
- Weft.configure do |c|
79
- c.auto_reload = true
80
- c.reload_paths = [File.join(APP_ROOT, "app", "**", "*.rb")]
81
- end
71
+ Weft.configure_autoloading(
72
+ paths: %w[data components pages].map { |dir| File.join(APP_ROOT, "app", dir) },
73
+ reload: true
74
+ )
82
75
  ```
83
76
 
84
77
  Two things to notice:
85
78
 
86
- - **Loading is just `require`.** Weft discovers your pages and components the moment their classes are defined — there's nothing to register. The directory ordering matters a little: if a component references another class *in its class body* (you'll see `includes AttendeeList` later), the referenced file has to load first. Our data components pages ordering plus alphabetical luck covers this tutorial; a growing app eventually wants a real autoloader like Zeitwerk.
87
- - **Turn on `auto_reload` before your first run.** In a moment you'll be editing files and refreshing the browser; with these two settings, your edits apply without restarting the server. (In a real app you'd gate this on an environment check — see [Configuration](configuration.md#auto_reload).)
79
+ - **Loading is managed for you.** `configure_autoloading` puts [Zeitwerk](https://github.com/fxn/zeitwerk) in charge of the `app` directories: each file defines the constant its name implies (`event_store.rb` `EventStore`), references between files resolve on demand, and load order is never your problem. Weft discovers your pages and components the moment their classes load there's nothing to register.
80
+ - **`reload: true` is the development loop.** In a moment you'll be editing files and refreshing the browser; with it, your edits apply without restarting the server. (In a real app you'd gate it on an environment check — see [Configuration](configuration.md#autoloading-weftconfigure_autoloading).)
88
81
 
89
82
  ## 3. Your first page
90
83
 
@@ -92,8 +85,9 @@ Create `app/pages/events_page.rb`:
92
85
 
93
86
  ```ruby
94
87
  class EventsPage < Weft::Page
88
+ title "Upcoming Events"
89
+
95
90
  def build(attributes = {})
96
- attributes[:title] = "Upcoming Events"
97
91
  super
98
92
  h1 "Upcoming Events"
99
93
  para "If you can read this in the browser, the app is wired up."
@@ -101,7 +95,7 @@ class EventsPage < Weft::Page
101
95
  end
102
96
  ```
103
97
 
104
- A page is a class. `build` describes its content using [Arbre](arbre.md)'s HTML builder methods — `h1`, `ul`, `div`, and friends — as plain Ruby. The `super` call renders the document shell around you: doctype, `<head>` with the htmx script, `<body>`. Setting `attributes[:title]` before `super` puts your title in the `<head>`.
98
+ A page is a class. The `title` declaration names the browser tab; `build` describes the content using [Arbre](arbre.md)'s HTML builder methods — `h1`, `ul`, `div`, and friends — as plain Ruby. The `super` call renders the document shell around you: doctype, `<head>` with your title and the htmx script, `<body>`.
105
99
 
106
100
  Start the server and have a look:
107
101
 
@@ -124,7 +118,7 @@ Nobody told Weft about that URL. The route came from the class name: `EventsPage
124
118
  Two more things worth ten seconds each while the server is up:
125
119
 
126
120
  - Visit [http://localhost:9292/](http://localhost:9292/) — a styled "Not found" page, for free. Weft ships default error and not-found handling out of the box ([Error handling](error-handling.md)).
127
- - Edit the `para` text and refresh — the change appears without a restart. That's `auto_reload` earning its keep.
121
+ - Edit the `para` text and refresh — the change appears without a restart. That's `reload: true` earning its keep.
128
122
 
129
123
  ## 4. Some data
130
124
 
@@ -157,8 +151,9 @@ And make `EventsPage` list the real events:
157
151
 
158
152
  ```ruby
159
153
  class EventsPage < Weft::Page
154
+ title "Upcoming Events"
155
+
160
156
  def build(attributes = {})
161
- attributes[:title] = "Upcoming Events"
162
157
  super
163
158
  h1 "Upcoming Events"
164
159
  ul do
@@ -175,9 +170,9 @@ end
175
170
 
176
171
  (`text_node` inserts plain text next to other elements — handy when a line mixes a link and loose text.)
177
172
 
178
- **Restart the server for this one.** `auto_reload` re-runs files it already knows about, but `event_store.rb` is a *new* file — the loader glob ran at boot, before it existed. If you refresh without restarting, you'll get Weft's error page with `uninitialized constant EventsPage::EventStore`, which is your cue. New file → restart; edits to existing files → just refresh.
173
+ **No restart needed not even for a new file.** `event_store.rb` didn't exist when the server booted, but the loader discovers it on your next refresh, and `EventsPage`'s `EventStore` reference resolves on demand.
179
174
 
180
- After the restart, `/events` lists both events as links. They 404 — let's fix that.
175
+ Refresh `/events`: both events appear as links. They 404 — let's fix that.
181
176
 
182
177
  ## 5. The event page
183
178
 
@@ -187,13 +182,14 @@ Create `app/pages/event_page.rb`:
187
182
  class EventPage < Weft::Page
188
183
  self.page_path = "/events/:event_id"
189
184
 
190
- attribute :event_id
185
+ param :event_id
186
+
187
+ title { |params| EventStore.find(params.event_id).name }
191
188
 
192
189
  def build(attributes = {})
193
- event = EventStore.find(attributes[:event_id])
194
- raise Weft::NotFound, "no event called #{attributes[:event_id]}" unless event
190
+ event = EventStore.find(params.event_id)
191
+ raise Weft::NotFound, "no event called #{params.event_id}" unless event
195
192
 
196
- attributes[:title] = event.name
197
193
  super
198
194
  h1 event.name
199
195
  para "#{event.date} — #{event.location}"
@@ -204,9 +200,9 @@ end
204
200
 
205
201
  Restart (new file), then click through to an event. Two new ideas here:
206
202
 
207
- **Attributes are a page's wire state.** `attribute :event_id` declares that this page is parameterized, and the `page_path` pattern says where the value comes from: `/events/summer-bbq` gives the page `event_id = "summer-bbq"`. A page with attributes needs an explicit `page_path` — there's no way to derive a parameterized pattern from a class name, and Weft will tell you exactly that if you forget.
203
+ **Params are a page's wire state.** `param :event_id` declares that this page is parameterized, and the `page_path` pattern says where the value comes from: `/events/summer-bbq` gives the page `event_id = "summer-bbq"`. A page with params needs an explicit `page_path` — there's no way to derive a parameterized pattern from a class name, and Weft will tell you exactly that if you forget.
208
204
 
209
- One timing wrinkle: before the `super` call, read incoming values from the raw `attributes` hash (as above). After `super`, the resolved values are available the nicer way `attrs.event_id`. You'll see `attrs` used in the components below, where `super` comes first.
205
+ **The title can be dynamic.** The block form of `title` receives the page's resolved params, so each event names its own browser tab. The block runs while `super` renders the `<head>` after the guard at the top of `build` has already raised for a bogus id, so it can assume the event exists. (And looking the event up twice is fine against a hash; when lookups get real, [`derives`](params.md#four-doors-how-a-component-gets-what-it-needs) declares the record once and every reader — the title block included shares it.)
210
206
 
211
207
  **Raising is error handling.** For an unknown event, we `raise Weft::NotFound` and we're done — Weft turns it into a proper 404 response with its default not-found page. Try [http://localhost:9292/events/nope](http://localhost:9292/events/nope). There's a whole family of semantic errors (`Weft::Unprocessable` will appear shortly), and everything about the resulting rendering is customizable — see [Error handling](error-handling.md).
212
208
 
@@ -218,11 +214,11 @@ Pages are destinations; **components** are the composable, interactive pieces in
218
214
  class AttendeeList < Weft::Component
219
215
  builder_method :attendee_list
220
216
 
221
- attribute :event_id
217
+ param :event_id
222
218
 
223
219
  def build(attributes = {})
224
220
  super
225
- event = EventStore.find(attrs.event_id)
221
+ event = EventStore.find(params.event_id)
226
222
  h2 "Who's coming"
227
223
  if event.rsvps.empty?
228
224
  para "No RSVPs yet. Be the first!"
@@ -242,9 +238,11 @@ end
242
238
  Add it to `EventPage`, before the back-link:
243
239
 
244
240
  ```ruby
245
- attendee_list(event_id: event.id)
241
+ attendee_list
246
242
  ```
247
243
 
244
+ Notice there's no `event_id:` here. The component is nested inside a page that already carries `event_id`, and params flow down the render tree — so `attendee_list` inherits it automatically. That inheritance is central to how Weft composes; the [DSL reference](dsl.md#inheritance-and-the-render-tree) has the full picture.
245
+
248
246
  Restart, and the Summer BBQ page shows Priya under "Who's coming".
249
247
 
250
248
  View the page source and look at the wrapper Weft rendered:
@@ -253,7 +251,7 @@ View the page source and look at the wrapper Weft rendered:
253
251
  <div id="attendee-list-summer-bbq">
254
252
  ```
255
253
 
256
- That DOM id was derived, not written: the class name, plus the value of the component's **first declared attribute**. The convention matters — it's how updates land on the right element when several instances share a page — so declare the identifying attribute first. (A list of attendee *rows*, say, would want `attribute :name` first, or every row would collide on the same event-derived id.)
254
+ That DOM id was derived, not written: the class name, plus the value of the component's **first declared param**. The convention matters — it's how updates land on the right element when several instances share a page — so declare the identifying param first. (A list of attendee *rows*, say, would want `param :name` first, or every row would collide on the same event-derived id.)
257
255
 
258
256
  One more thing, and it's the heart of Weft. Your component isn't just markup inside the page — it's independently addressable:
259
257
 
@@ -271,32 +269,32 @@ Now the interactive part. Create `app/components/rsvp_form.rb`:
271
269
  class RSVPForm < Weft::Component
272
270
  builder_method :rsvp_form
273
271
 
274
- attribute :event_id
275
- attribute :name
276
- attribute :answer
277
- attribute :error_message
272
+ param :event_id
273
+ param :name
274
+ param :answer
275
+ param :error_message
278
276
 
279
277
  includes AttendeeList
280
278
 
281
- performs :submit do |attrs|
282
- event = EventStore.find(attrs.event_id)
283
- name = attrs.name.to_s.strip
279
+ performs :submit do |params|
280
+ event = EventStore.find(params.event_id)
281
+ name = params.name.to_s.strip
284
282
  raise Weft::Unprocessable, "Please tell us your name." if name.empty?
285
283
 
286
- event.rsvps[name] = attrs.answer
284
+ event.rsvps[name] = params.answer
287
285
  nil
288
286
  end
289
287
 
290
- recovers from: Weft::Unprocessable do |_attrs, error|
288
+ recovers from: Weft::Unprocessable do |_params, error|
291
289
  { error_message: error.message }
292
290
  end
293
291
 
294
292
  def build(attributes = {})
295
293
  super
296
294
  h2 "RSVP"
297
- para(attrs.error_message, style: "color:#b91c1c") if attrs.error_message
295
+ para(params.error_message, style: "color:#b91c1c") if params.error_message
298
296
  form(action: :submit) do
299
- input(type: "hidden", name: "event_id", value: attrs.event_id)
297
+ input(type: "hidden", name: "event_id", value: params.event_id)
300
298
  label("Your name: ", for: "name")
301
299
  input(type: "text", name: "name", id: "name")
302
300
  label(" Coming? ", for: "answer")
@@ -312,15 +310,15 @@ end
312
310
  Add it to `EventPage` above the attendee list:
313
311
 
314
312
  ```ruby
315
- rsvp_form(event_id: event.id)
316
- attendee_list(event_id: event.id)
313
+ rsvp_form
314
+ attendee_list
317
315
  ```
318
316
 
319
317
  Restart, open Trivia Night, RSVP as yourself — **the attendee list updates without a page reload**, and the form clears. Then try submitting with a blank name: a red message appears in the form, and the list is untouched.
320
318
 
321
319
  That's a lot from one class. Unpacking it:
322
320
 
323
- **`performs :submit` declares a user action.** The block is the behavior: it receives the component's resolved attributes, does its work, and whatever it returns directs what renders next — `nil` means "re-render me fresh" (our success path). Weft generates the route for the action; you never wrote one.
321
+ **`performs :submit` declares a user action.** The block is the behavior: it receives the component's resolved params, does its work, and whatever it returns directs what renders next — `nil` means "re-render me fresh" (our success path). Weft generates the route for the action; you never wrote one.
324
322
 
325
323
  **`form(action: :submit)` wires the form to the action.** Look at the rendered HTML:
326
324
 
@@ -331,15 +329,15 @@ That's a lot from one class. Unpacking it:
331
329
 
332
330
  The `hx-*` attributes make the form submit in place. The plain `action` and `method` attributes are there too, so the form still works with JavaScript disabled — it degrades to a normal POST.
333
331
 
334
- **Form fields map to declared attributes, one to one.** The action block reads `attrs.name` and `attrs.answer` because the form has fields named `name` and `answer` *and* the component declares attributes of the same names. Both halves are needed: declared-but-not-a-field values don't travel (that's why `event_id` rides along as a hidden input — it's part of the component's identity, not something the user types), and field-but-not-declared values are ignored.
332
+ **Form fields map to declared params, one to one.** The action block reads `params.name` and `params.answer` because the form has fields named `name` and `answer` *and* the component declares params of the same names. Both halves are needed: declared-but-not-a-field values don't travel (that's why `event_id` rides along as a hidden input — it's part of the component's identity, not something the user types), and field-but-not-declared values are ignored.
335
333
 
336
- **Validation is a raise plus a recovery.** The action raises `Weft::Unprocessable`; the `recovers` declaration catches it, and its block returns extra attributes to merge into the re-render — here, `error_message`, which `build` displays when present. Note that `error_message` is itself a declared attribute: recovery data flows through the same schema as everything else. The response even carries a semantically-correct 422 status. See [Error handling](error-handling.md) for how far this system goes.
334
+ **Validation is a raise plus a recovery.** The action raises `Weft::Unprocessable`; the `recovers` declaration catches it, and its block returns extra params to merge into the re-render — here, `error_message`, which `build` displays when present. Note that `error_message` is itself a declared param: recovery data flows through the same schema as everything else. The response even carries a semantically-correct 422 status. See [Error handling](error-handling.md) for how far this system goes.
337
335
 
338
336
  **`includes AttendeeList` updates the list in the same response.** Submitting the form changes data that *another* component displays. This declaration says: whenever RSVPForm responds to an action, render AttendeeList too, marked so it swaps into its own place in the page (by that derived DOM id — this is why the convention exists). One interaction, two regions updated, zero JavaScript.
339
337
 
340
338
  ## 8. Going live
341
339
 
342
- The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the attribute:
340
+ The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the param:
343
341
 
344
342
  ```ruby
345
343
  refreshes every: 10
@@ -359,13 +357,13 @@ Declared once on the class, the behavior is present in the initial page render *
359
357
 
360
358
  You've built pages that route themselves, components that compose and self-address, a validated user action with out-of-band updates, and a live-polling list — the core of how Weft apps are put together.
361
359
 
362
- **An exercise, if you're enjoying yourself:** add a "withdraw" button next to each attendee. You'll want a per-attendee component (careful which attribute you declare first — each row needs its own DOM id), and the `dismisses` verb, which removes a component from the page when its action succeeds. The [DSL reference](dsl.md#dismisses--remove-from-the-dom) has what you need.
360
+ **An exercise, if you're enjoying yourself:** add a "withdraw" button next to each attendee. You'll want a per-attendee component (careful which param you declare first — each row needs its own DOM id), and the `dismisses` verb, which removes a component from the page when its action succeeds. The [DSL reference](dsl.md#dismisses--remove-from-the-dom) has what you need.
363
361
 
364
362
  **A finishing touch:** the events list living at `/events` leaves `/` as a 404. Give `EventsPage` an explicit home: `self.page_path = "/"`.
365
363
 
366
364
  **The reference docs**, when you want the full picture:
367
365
 
368
- - [The Weft DSL](dsl.md) — every verb (`transfers`, `pushes`, `dismisses`, `triggers`…), the element kwargs, and the interaction shorthands (tooltips, modals, lazy loading) this tutorial didn't touch.
366
+ - [The Weft DSL](dsl.md) — every verb (`transfers`, `pushes`, `dismisses`, `triggers`…), the element kwargs, and the interaction presets (tooltips, modals, lazy loading) this tutorial didn't touch.
369
367
  - [Arbre: the HTML layer](arbre.md) — the HTML builder underneath every `build` method: its argument conventions, text handling, container patterns, and gotchas beyond `para`.
370
368
  - [Routing](routing.md) — how paths derive, what's routable, collision detection.
371
369
  - [Error handling](error-handling.md) — the error family, recovery chains, branding your error pages.
data/lib/weft/action.rb CHANGED
@@ -47,6 +47,8 @@ module Weft
47
47
 
48
48
  TRIGGER_VALUES = {
49
49
  click: "click",
50
+ click_once: "click once",
51
+ change: "change",
50
52
  hover: "mouseenter once",
51
53
  visible: "revealed",
52
54
  input: "input changed delay:300ms"
@@ -64,9 +66,9 @@ module Weft
64
66
  path = route_path(component.class.resolved_component_path)
65
67
  {
66
68
  "hx-#{method}" => path,
67
- "hx-target" => target || "##{component.weft_id}",
69
+ "hx-target" => target || "##{component.weft_dom_id}",
68
70
  "hx-swap" => self.class.resolve_swap(swap),
69
- "hx-vals" => component.attrs.to_h.to_json
71
+ "hx-vals" => component.serializable_params.to_json
70
72
  }
71
73
  end
72
74
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/error"
4
+ require "weft/router"
5
+
6
+ module Weft
7
+ # Zeitwerk-backed autoloading for application code, behind
8
+ # {Weft.configure_autoloading}. Each call builds one Zeitwerk loader over the
9
+ # given paths and eager-loads it immediately — Weft routes from the Registry,
10
+ # which populates via the `inherited` hook, so the classes must exist before
11
+ # the first request (lazy autoload alone would serve an empty route table).
12
+ #
13
+ # With reload: true, a per-request Router hook reloads constants, and
14
+ # Zeitwerk's on_unload callback evicts each outgoing class from the registry
15
+ # as it unloads — the push-model complement to {Registry#evict}, which
16
+ # hand-rolled reloaders call directly.
17
+ module Autoloading
18
+ class << self
19
+ # Loaders created so far, in creation order. @api private
20
+ def loaders
21
+ @loaders ||= []
22
+ end
23
+
24
+ def setup(paths:, inflections: {}, reload: false)
25
+ paths = Array(paths)
26
+ validate!(paths, inflections)
27
+ require "zeitwerk"
28
+
29
+ loader = build_loader(paths, inflections, reload)
30
+ loader.setup
31
+ loader.eager_load
32
+ install_reload_hook(loader) if reload
33
+ loaders << loader
34
+ loader
35
+ end
36
+
37
+ private
38
+
39
+ def build_loader(paths, inflections, reload)
40
+ loader = Zeitwerk::Loader.new
41
+ paths.each { |dir| loader.push_dir(dir) }
42
+ loader.inflector.inflect(inflections) unless inflections.empty?
43
+ loader.enable_reloading if reload # must precede setup — Zeitwerk's contract
44
+ loader.on_unload { |_cpath, value, _abspath| Weft.registry.evict(value) }
45
+ loader
46
+ end
47
+
48
+ def validate!(paths, inflections)
49
+ raise Weft::InvalidConfiguration, "configure_autoloading requires at least one path" if paths.empty?
50
+ return if inflections.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
51
+
52
+ raise Weft::InvalidConfiguration,
53
+ "configure_autoloading inflections must map file basenames to constant names " \
54
+ "(String => String), got #{inflections.inspect}"
55
+ end
56
+
57
+ # The dev-mode request hook: reload constants (evicting via on_unload as
58
+ # they unload), eager-load so everything re-registers, then rebind any
59
+ # configuration knob left holding a superseded class.
60
+ def install_reload_hook(loader)
61
+ Router.before do
62
+ loader.reload
63
+ loader.eager_load
64
+ Weft.configuration.refresh_stale_classes!
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end