weft 0.1.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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +150 -0
  5. data/docs/app-patterns.md +268 -0
  6. data/docs/arbre.md +298 -0
  7. data/docs/configuration.md +219 -0
  8. data/docs/dsl.md +329 -0
  9. data/docs/error-handling.md +136 -0
  10. data/docs/examples/README.md +70 -0
  11. data/docs/examples/active-search.md +125 -0
  12. data/docs/examples/browser-dialogs.md +71 -0
  13. data/docs/examples/bulk-update.md +122 -0
  14. data/docs/examples/click-to-edit.md +113 -0
  15. data/docs/examples/click-to-load.md +77 -0
  16. data/docs/examples/delete-row.md +101 -0
  17. data/docs/examples/edit-row.md +146 -0
  18. data/docs/examples/file-upload.md +96 -0
  19. data/docs/examples/infinite-scroll.md +98 -0
  20. data/docs/examples/inline-expansion.md +98 -0
  21. data/docs/examples/inline-validation.md +101 -0
  22. data/docs/examples/keyboard-shortcuts.md +71 -0
  23. data/docs/examples/lazy-loading.md +96 -0
  24. data/docs/examples/live-ticker.md +91 -0
  25. data/docs/examples/modal-dialog.md +88 -0
  26. data/docs/examples/progress-bar.md +138 -0
  27. data/docs/examples/reset-user-input.md +101 -0
  28. data/docs/examples/tabs.md +102 -0
  29. data/docs/examples/tooltip.md +88 -0
  30. data/docs/examples/updating-other-content.md +169 -0
  31. data/docs/examples/value-select.md +109 -0
  32. data/docs/routing.md +131 -0
  33. data/docs/tutorial.md +372 -0
  34. data/lib/weft/action.rb +83 -0
  35. data/lib/weft/attributes.rb +65 -0
  36. data/lib/weft/component.rb +176 -0
  37. data/lib/weft/configuration.rb +177 -0
  38. data/lib/weft/context/interception.rb +22 -0
  39. data/lib/weft/context.rb +184 -0
  40. data/lib/weft/defaults/error_component.rb +63 -0
  41. data/lib/weft/defaults/error_page.rb +27 -0
  42. data/lib/weft/defaults/not_found_component.rb +44 -0
  43. data/lib/weft/defaults/not_found_page.rb +25 -0
  44. data/lib/weft/defaults.rb +9 -0
  45. data/lib/weft/dsl/actions.rb +72 -0
  46. data/lib/weft/dsl/attributes.rb +43 -0
  47. data/lib/weft/dsl/containers.rb +77 -0
  48. data/lib/weft/dsl/inclusions.rb +49 -0
  49. data/lib/weft/dsl/recoveries.rb +89 -0
  50. data/lib/weft/dsl/triggers.rb +40 -0
  51. data/lib/weft/dsl/updates.rb +86 -0
  52. data/lib/weft/error.rb +64 -0
  53. data/lib/weft/page.rb +371 -0
  54. data/lib/weft/redirect.rb +45 -0
  55. data/lib/weft/registry/eligibility.rb +58 -0
  56. data/lib/weft/registry.rb +202 -0
  57. data/lib/weft/resolver.rb +33 -0
  58. data/lib/weft/router/actions.rb +77 -0
  59. data/lib/weft/router/errors.rb +246 -0
  60. data/lib/weft/router/oob_includes.rb +34 -0
  61. data/lib/weft/router/streaming.rb +63 -0
  62. data/lib/weft/router.rb +191 -0
  63. data/lib/weft/shorthands.rb +57 -0
  64. data/lib/weft/version.rb +5 -0
  65. data/lib/weft.rb +124 -0
  66. metadata +169 -0
@@ -0,0 +1,88 @@
1
+ # Tooltip
2
+
3
+ Hovering over an element fetches a small piece of server-rendered detail — a profile card, a definition, a status readout — and places it in a bubble beside the trigger. The detail is rendered fresh from the server on first hover, then stays put.
4
+
5
+ There's no counterpart for this in the htmx examples catalog; `tooltip:` is Weft-native sugar over the same [`loads:`](../dsl.md#loads) machinery as the other shorthands. It earns its keep when the tooltip content is worth a server round-trip — live data, per-record queries — rather than static text a `title=` attribute could carry.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ TEAM = {
11
+ "priya" => { role: "Backend", timezone: "UTC+5:30", focus: "payments migration" },
12
+ "marco" => { role: "Design", timezone: "UTC+1", focus: "checkout redesign" },
13
+ "june" => { role: "Support", timezone: "UTC−8", focus: "triage rotation" }
14
+ }.freeze
15
+
16
+ class MemberPeek < Weft::Component
17
+ builder_method :member_peek
18
+
19
+ attribute :handle
20
+
21
+ def build(attributes = {})
22
+ super
23
+ member = TEAM.fetch(attrs.handle)
24
+ strong attrs.handle.capitalize
25
+ para "#{member[:role]} — #{member[:timezone]}"
26
+ para "Focus: #{member[:focus]}"
27
+ end
28
+ end
29
+
30
+ class TeamRoster < Weft::Component
31
+ builder_method :team_roster
32
+
33
+ def build(attributes = {})
34
+ super
35
+ h3 "On the project"
36
+ ul do
37
+ TEAM.each_key do |handle|
38
+ li style: "position: relative" do
39
+ span handle.capitalize, class: "peek-trigger",
40
+ tooltip: MemberPeek, with: { handle: handle }, target: "#peek-#{handle}"
41
+ div id: "peek-#{handle}", class: "peek-bubble",
42
+ style: "position: absolute; left: 8rem; top: 0;"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ ```
49
+
50
+ (The `TEAM` hash stands in for your data layer; the inline styles are the minimum to float each bubble beside its row — real styling belongs in your stylesheet.)
51
+
52
+ ## How it works
53
+
54
+ **Hover fetches; the bubble receives.** [`tooltip:`](../dsl.md#shorthands) presets trigger `:hover` and swap `:fill`; the call site says where the content lands. Each name targets its own empty bubble div (`target: "#peek-#{handle}"`), so every row has an independent tooltip slot. The bubbles start empty and cost nothing until hovered.
55
+
56
+ **`:hover` means *first* hover.** The semantic trigger expands to htmx's `mouseenter once` — the fetch happens the first time the pointer enters, and never again. Be clear about what that buys and what it doesn't: Weft delivers the *content*, once, lazily. It does not show and hide the bubble as the pointer comes and goes — that's presentation, and it belongs to CSS (a `.peek-bubble` hidden until `li:hover`, for instance). After the first hover, showing the tooltip again is free, because the content is already in the page.
57
+
58
+ **Per-row wire attrs, one component.** Every trigger loads the same `MemberPeek` class with a different `with: { handle: ... }` — the component is written once and addressed per record, which is the same shape as every list-plus-detail pattern in this catalog.
59
+
60
+ ## On the wire
61
+
62
+ The initial render — each name wired, each bubble empty:
63
+
64
+ ```html
65
+ <li style="position: relative">
66
+ <span class="peek-trigger" hx-get="/_components/member_peek?handle=priya"
67
+ hx-swap="innerHTML" hx-target="#peek-priya"
68
+ hx-trigger="mouseenter once">Priya</span>
69
+ <div id="peek-priya" class="peek-bubble"
70
+ style="position: absolute; left: 8rem; top: 0;"></div>
71
+ </li>
72
+ ```
73
+
74
+ The first hover over Priya issues `GET /_components/member_peek?handle=priya`, and the bubble fills:
75
+
76
+ ```html
77
+ <div id="member-peek-priya">
78
+ <strong>Priya</strong>
79
+ <p>Backend — UTC+5:30</p>
80
+ <p>Focus: payments migration</p>
81
+ </div>
82
+ ```
83
+
84
+ ## Related
85
+
86
+ - [Inline Expansion](inline-expansion.md) — the other Weft-native shorthand: click-driven detail that lands *after* its trigger instead of in a bubble.
87
+ - [Lazy Loading](lazy-loading.md) — the same load-once deferral, triggered by visibility instead of the pointer.
88
+ - The [shorthands table](../dsl.md#shorthands) in the DSL reference.
@@ -0,0 +1,169 @@
1
+ # Updating Other Content
2
+
3
+ A form adds a contact, and a contacts table elsewhere on the page — outside the form, not enclosing it, not enclosed by it — updates in the same interaction. One submit, two regions refreshed.
4
+
5
+ This is Weft's take on [htmx's update-other-content example](https://htmx.org/examples/update-other-content/), which uses the same scenario: a table of contacts with an add-contact form below it. htmx's page is really an essay weighing four manual solutions — expanding the target, out-of-band swaps, event triggers, and a path-dependencies extension — each with its own wiring to write and trade-offs to hold in your head. Weft collapses the choice to declarations: `includes ContactsTable` on the form covers the common case in one line, and a `triggers`/`refreshes on:` pair covers the decoupled case in two. [The full four-way mapping is below.](#htmxs-four-solutions-mapped)
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ ADDRESS_BOOK = [
11
+ { name: "Joe Smith", email: "joe@smith.org" }
12
+ ]
13
+
14
+ class ContactsTable < Weft::Component
15
+ builder_method :contacts_table
16
+
17
+ def build(attributes = {})
18
+ super
19
+ table do
20
+ thead { tr { th "Name"; th "Email" } }
21
+ tbody do
22
+ ADDRESS_BOOK.each { |contact| tr { td contact[:name]; td contact[:email] } }
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class NewContactForm < Weft::Component
29
+ builder_method :new_contact_form
30
+
31
+ attribute :name
32
+ attribute :email
33
+
34
+ includes ContactsTable
35
+
36
+ performs :add do |attrs|
37
+ ADDRESS_BOOK << { name: attrs.name, email: attrs.email }
38
+ { name: nil, email: nil }
39
+ end
40
+
41
+ def build(attributes = {})
42
+ super
43
+ h3 "Add a contact"
44
+ form(action: :add) do
45
+ label("Name ", for: "name")
46
+ input(type: "text", name: "name", id: "name")
47
+ label(" Email ", for: "email")
48
+ input(type: "email", name: "email", id: "email")
49
+ input(type: "submit", value: "Add Contact")
50
+ end
51
+ end
52
+ end
53
+ ```
54
+
55
+ And the page that places them side by side:
56
+
57
+ ```ruby
58
+ class ContactsPage < Weft::Page
59
+ def build(attributes = {})
60
+ attributes[:title] = "Contacts"
61
+ super
62
+ h1 "Contacts"
63
+ contacts_table
64
+ new_contact_form
65
+ end
66
+ end
67
+ ```
68
+
69
+ (`ADDRESS_BOOK` stands in for your data layer, as usual.)
70
+
71
+ ## How it works
72
+
73
+ **`includes` declares the relationship once, in the class body.** [`includes ContactsTable`](../dsl.md#includes--companions-in-the-same-response) means: whenever this form responds to an action, render the table too, marked out-of-band. htmx receives one response containing two fragments — the re-rendered form swaps into the form's place as usual, and the table fragment, carrying `hx-swap-oob="true"`, is routed to its own DOM slot by id (`#contacts-table`). One request, one response, two regions updated. This is htmx's out-of-band solution with the response construction, the OOB attribute, and the id bookkeeping all handled for you.
74
+
75
+ **The included component needs no route.** In this variant `ContactsTable` declares no attributes and no verbs, so it isn't independently addressable — `GET /_components/contacts_table` answers 404 — and that's fine: it renders inside the page and travels inside the form's responses. Companions only need to *render* (see [routing](../routing.md#routable-vs-render-target)).
76
+
77
+ **The callable resets the form.** An action callable's return value directs the re-render ([the callable contract](../dsl.md#the-callable-contract)): returning a hash merges it into the attrs. Returning `{ name: nil, email: nil }` clears the just-submitted values, so the form comes back empty after each add — htmx's reset-the-form problem solved server-side, with no `hx-on` handler. (It also keeps the component's derived DOM id, which is built from the first attribute's value, stable across renders.)
78
+
79
+ **Form fields pair with declared attributes.** The form declares `name` and `email` so the submitted fields reach the callable as `attrs.name` and `attrs.email` — the same pairing as every Weft form (covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps)). And since `form(action: :add)` also emits plain `action`/`method` attributes, the add still works without JavaScript; only the tableside update needs htmx.
80
+
81
+ ## The decoupled variant
82
+
83
+ `includes` is directional: the form knows the table exists. Sometimes it shouldn't — the reacting components may be many, elsewhere, or someone else's. Then the form *announces* and interested components *listen*:
84
+
85
+ ```ruby
86
+ class ContactsTable < Weft::Component
87
+ # …exactly as before, plus one declaration:
88
+ refreshes on: "contact-added"
89
+ end
90
+
91
+ class NewContactForm < Weft::Component
92
+ # …exactly as before, but in place of `includes ContactsTable`:
93
+ triggers "contact-added"
94
+ end
95
+ ```
96
+
97
+ [`triggers`](../dsl.md#triggers--announce-to-the-rest-of-the-page) stamps every action response from the form with an `HX-Trigger: contact-added` header; htmx fires that as an event on the page body. [`refreshes on:`](../dsl.md#refreshes--the-client-re-fetches) wires the table's wrapper to listen for it (`from:body`) and re-fetch its own route. The two components never mention each other — any number of components can subscribe to `"contact-added"` without the form changing at all. The trade: each listener re-fetches itself, so a submit costs one extra GET per listener (and the table must now be routable, which its `refreshes` declaration itself ensures).
98
+
99
+ **Choosing between them:** reach for `includes` when the form naturally knows what it changes — everything arrives in the same response, zero extra requests. Reach for `triggers` when the reactions should be open-ended or the components shouldn't know about each other.
100
+
101
+ ## htmx's four solutions, mapped
102
+
103
+ | htmx's solution | In Weft |
104
+ | --- | --- |
105
+ | 1. Expand the target | Still available — wrap both regions in one component and re-render it whole — but rarely needed once the next two are declarations. |
106
+ | 2. Out-of-band responses | `includes ContactsTable` on the form. |
107
+ | 3. Triggering events | `triggers "contact-added"` on the form, `refreshes on: "contact-added"` on the table. |
108
+ | 4. Path dependencies (extension) | Not needed — solutions 2 and 3 as declarations cover both coupling directions without an extension. |
109
+
110
+ ## On the wire
111
+
112
+ The initial page render — two independent regions, and in this variant the table wrapper carries no wiring at all:
113
+
114
+ ```html
115
+ <div id="contacts-table">
116
+ <table>…Joe Smith…</table>
117
+ </div>
118
+ <div id="new-contact-form">
119
+ <h3>Add a contact</h3>
120
+ <form hx-post="/_components/new_contact_form/add" hx-target="#new-contact-form"
121
+ hx-swap="outerHTML" action="/_components/new_contact_form/add" method="post">
122
+
123
+ </form>
124
+ </div>
125
+ ```
126
+
127
+ Submitting `POST /_components/new_contact_form/add` with `name=Angie MacDowell&email=angie@macdowell.org` returns one response holding both fragments — the emptied form, then the table marked out-of-band, new row included:
128
+
129
+ ```html
130
+ <div id="new-contact-form">
131
+ <h3>Add a contact</h3>
132
+ <form hx-post="/_components/new_contact_form/add" hx-target="#new-contact-form"
133
+ hx-swap="outerHTML" action="/_components/new_contact_form/add" method="post">
134
+ …fresh, empty fields…
135
+ </form>
136
+ </div>
137
+ <div id="contacts-table" hx-swap-oob="true">
138
+ <table>
139
+ <thead><tr><th>Name</th><th>Email</th></tr></thead>
140
+ <tbody>
141
+ <tr><td>Joe Smith</td><td>joe@smith.org</td></tr>
142
+ <tr><td>Angie MacDowell</td><td>angie@macdowell.org</td></tr>
143
+ </tbody>
144
+ </table>
145
+ </div>
146
+ ```
147
+
148
+ In the decoupled variant, the same submit instead answers with the form alone plus the event header:
149
+
150
+ ```
151
+ HTTP/1.1 200 OK
152
+ content-type: text/html;charset=utf-8
153
+ hx-trigger: contact-added
154
+ ```
155
+
156
+ …and the table, whose wrapper rendered as
157
+
158
+ ```html
159
+ <div id="contacts-table" hx-get="/_components/contacts_table"
160
+ hx-trigger="contact-added from:body" hx-swap="outerHTML">
161
+ ```
162
+
163
+ hears the event and issues `GET /_components/contacts_table`, which returns the fresh table with both rows.
164
+
165
+ ## Related
166
+
167
+ - [Click to Edit](click-to-edit.md) — the form-fields-pair-with-attributes pattern this example builds on.
168
+ - [`includes`](../dsl.md#includes--companions-in-the-same-response), [`triggers`](../dsl.md#triggers--announce-to-the-rest-of-the-page), and [`refreshes`](../dsl.md#refreshes--the-client-re-fetches) in the DSL reference.
169
+ - `includes` accepts `on: :action_name` to scope a companion to one action, and a block to map the primary component's attrs onto the companion's — see [the DSL reference](../dsl.md#includes--companions-in-the-same-response).
@@ -0,0 +1,109 @@
1
+ # Value Select
2
+
3
+ Two selects, where the first drives the second: pick a car make, and the model select repopulates with that make's models — no page reload, no hand-written JavaScript.
4
+
5
+ This is Weft's take on [htmx's cascading-selects example](https://htmx.org/examples/value-select/), with one structural difference. Their server returns bare `<option>` tags that swap into the model select's interior; a Weft component brings its own wrapper element, so here the model select *is* the component — changing the make fetches a fresh `<select>` and swaps it into a stable slot.
6
+
7
+ ## The components
8
+
9
+ ```ruby
10
+ CAR_MODELS = {
11
+ "audi" => %w[A1 A4 A6],
12
+ "toyota" => %w[Landcruiser Tacoma Yaris],
13
+ "bmw" => %w[325i 325ix X5]
14
+ }.freeze
15
+
16
+ class ModelSelect < Weft::Component
17
+ builder_method :model_select
18
+
19
+ attribute :make, default: "audi"
20
+
21
+ def build(attributes = {})
22
+ super
23
+ set_attribute :name, "model"
24
+ CAR_MODELS.fetch(attrs.make).each do |model|
25
+ option model, value: model.downcase
26
+ end
27
+ end
28
+
29
+ def tag_name
30
+ "select"
31
+ end
32
+ end
33
+ ```
34
+
35
+ The make select needs no component of its own — it's plain markup in the page, with the Weft wiring as kwargs:
36
+
37
+ ```ruby
38
+ class CarsPage < Weft::Page
39
+ def build(attributes = {})
40
+ super
41
+ div do
42
+ label "Make ", for: "make"
43
+ select name: "make", id: "make",
44
+ loads: ModelSelect, trigger: "change", swap: :fill, target: "#models" do
45
+ option "Audi", value: "audi"
46
+ option "Toyota", value: "toyota"
47
+ option "BMW", value: "bmw"
48
+ end
49
+ end
50
+ div do
51
+ label "Model "
52
+ span id: "models" do
53
+ model_select(make: "audi")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ ```
59
+
60
+ (The `CAR_MODELS` hash stands in for your data layer, as usual.)
61
+
62
+ ## How it works
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`.
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.
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.
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.
71
+
72
+ ## On the wire
73
+
74
+ The initial render of the two selects:
75
+
76
+ ```html
77
+ <select name="make" id="make" hx-get="/_components/model_select"
78
+ hx-swap="innerHTML" hx-target="#models" hx-trigger="change">
79
+ <option value="audi">Audi</option>
80
+ <option value="toyota">Toyota</option>
81
+ <option value="bmw">BMW</option>
82
+ </select>
83
+ ...
84
+ <span id="models">
85
+ <select id="model-select-audi" name="model">
86
+ <option value="a1">A1</option>
87
+ <option value="a4">A4</option>
88
+ <option value="a6">A6</option>
89
+ </select>
90
+ </span>
91
+ ```
92
+
93
+ Choosing Toyota sends `GET /_components/model_select?make=toyota`, which returns the fresh select:
94
+
95
+ ```html
96
+ <select id="model-select-toyota" name="model">
97
+ <option value="landcruiser">Landcruiser</option>
98
+ <option value="tacoma">Tacoma</option>
99
+ <option value="yaris">Yaris</option>
100
+ </select>
101
+ ```
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.
104
+
105
+ ## Related
106
+
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.
data/docs/routing.md ADDED
@@ -0,0 +1,131 @@
1
+ # Routing
2
+
3
+ Weft has no routes file. Defining a `Weft::Component` or `Weft::Page` subclass registers it, and `Weft::Router` serves it: components as HTML fragments at derived paths, pages as full documents at their declared patterns, actions and SSE streams at paths derived from their components. This document is the reference for how those routes come to be — and how to control the parts you sometimes need to.
4
+
5
+ **In this document:**
6
+
7
+ - [Mounting the Router](#mounting-the-router)
8
+ - [Component routes](#component-routes), including [action routes](#action-routes) and [stream endpoints](#stream-endpoints)
9
+ - [Page routes](#page-routes)
10
+ - [What routes — and what doesn't](#what-routes--and-what-doesnt), including [`abstract!` and `routable!`](#abstract-and-routable) and [routable vs. render target](#routable-vs-render-target)
11
+ - [Collision detection](#collision-detection)
12
+ - [Code reloading](#code-reloading)
13
+
14
+ ## Mounting the Router
15
+
16
+ `Weft::Router` is Rack middleware. It serves the requests it recognizes and passes the rest through:
17
+
18
+ ```ruby
19
+ # config.ru — alongside an existing app
20
+ use Weft::Router
21
+ run MyApp
22
+
23
+ # config.ru — standalone, Weft is the whole app
24
+ run Weft::Router
25
+ ```
26
+
27
+ In standalone mode there is no downstream app, so unmatched requests render the configured not-found page instead (see [Error handling](error-handling.md)).
28
+
29
+ One prerequisite worth knowing: registration happens when a class is *defined* (via Ruby's `inherited` hook), so your component and page files must be loaded before the first request. If you use an autoloader, eager-load these directories at boot.
30
+
31
+ ## Component routes
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).
34
+
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
+
37
+ | Class | Route |
38
+ | --- | --- |
39
+ | `OrdersPanel` | `/_components/orders_panel` |
40
+ | `OrdersPanelComponent` | `/_components/orders_panel` |
41
+ | `Oms::OrderHeader` | `/_components/oms/order_header` |
42
+
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
+
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)).
46
+
47
+ To change the path for one class, set `component_path` on it — a string, or a proc receiving the class:
48
+
49
+ ```ruby
50
+ class OrdersPanel < Weft::Component
51
+ self.component_path = "/api/panels/orders"
52
+ end
53
+ ```
54
+
55
+ The setting is inherited by subclasses (handy on an abstract base for a whole family). To change the convention app-wide instead, replace the [`component_path` configuration proc](configuration.md#component_path).
56
+
57
+ ### Action routes
58
+
59
+ Actions declared with `performs`/`transfers`/`dismisses` route under their component's path:
60
+
61
+ - **Named actions** get a subpath: `performs :advance` on `Oms::OrderHeader` routes at `POST /_components/oms/order_header/advance`.
62
+ - **Nameless actions** route at the component's own path, distinguished by HTTP method: `performs(method: :delete)` answers `DELETE /_components/oms/order_header`. A nameless GET action intercepts the component's render route itself.
63
+
64
+ The HTTP method comes from the declaration's `method:` kwarg (default `:post`); the Router answers GET, POST, PUT, DELETE, and PATCH.
65
+
66
+ ### Stream endpoints
67
+
68
+ A component declaring `pushes` also gets an SSE endpoint at its path plus the stream suffix — by default, `/_components/order_feed/_stream`. The component's rendered `sse-connect` URL and the Router's stream handling both derive from the same [`stream_suffix` setting](configuration.md#stream_suffix), so they can't drift apart. The suffix's leading underscore keeps stream endpoints from ever colliding with a nested component path, since path segments derived from Ruby class names can't begin with an underscore.
69
+
70
+ ## Page routes
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:
73
+
74
+ ```ruby
75
+ class OrderDetailPage < Weft::Page
76
+ self.page_path = "/orders/:order_id"
77
+ attribute :order_id
78
+ end
79
+ ```
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.
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).
84
+
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
+
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.
88
+ - A page named such that nothing usable remains after stripping (`Admin::Page`) also raises, with the remediation options spelled out.
89
+
90
+ ## What routes — and what doesn't
91
+
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
+
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).
96
+
97
+ ### `abstract!` and `routable!`
98
+
99
+ When the inference gets it wrong, override it — per class, in either direction:
100
+
101
+ ```ruby
102
+ class ApplicationPage < Weft::Page
103
+ abstract! # shared assets and recoveries; not itself a destination
104
+ end
105
+ ```
106
+
107
+ `abstract!` marks a class non-routable no matter what it declares; `routable!` forces the opposite. The override applies only to the class that declares it — subclasses of an abstract base infer (or declare) their own routability, so the common pattern of an abstract `ApplicationPage`/`ApplicationComponent` with concrete routable children just works.
108
+
109
+ ### Routable vs. render target
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.
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.
114
+
115
+ ## Collision detection
116
+
117
+ Distinct classes can resolve to the same route — two same-named classes in different namespaces under a custom `component_path` proc, a page pattern matching a component path, a class named such that suffix-stripping collides with a sibling. Rather than letting one silently shadow the other, Weft validates the whole route table on the first request: every routable component's path, each component's reserved stream endpoint, and every routable page pattern. Any duplicate raises `Weft::InvalidDefinition` naming both parties:
118
+
119
+ ```
120
+ Route collision on "/_components/orders_panel": component Oms::OrdersPanel and
121
+ component Admin::OrdersPanel resolve to the same route. Rename one class, set an
122
+ explicit component_path/page_path, or mark one abstract! if it should not route.
123
+ ```
124
+
125
+ The same validation rejects malformed paths (anything that isn't a string beginning with `/`) from a misbehaving custom proc. Validation runs once and is memoized; registering a new class re-arms it. Non-routable classes occupy no route and can never collide.
126
+
127
+ ## Code reloading
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.
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.