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
data/docs/tutorial.md ADDED
@@ -0,0 +1,372 @@
1
+ # Build your first Weft app
2
+
3
+ In this tutorial we'll build a small RSVP tracker: a list of events, a detail page per event, a form for RSVPing, and an attendee list that updates live. By the end you'll have written pages, components, a user action with validation, and a polling live update — with no routes file, no controllers, and no JavaScript.
4
+
5
+ Expect it to take twenty to thirty minutes. You'll need Ruby 3.2 or newer and basic familiarity with Bundler. Every step ends with something you can see working before moving on.
6
+
7
+ **The steps:**
8
+
9
+ - [1. Scaffold the project](#1-scaffold-the-project)
10
+ - [2. Wire up the app](#2-wire-up-the-app)
11
+ - [3. Your first page](#3-your-first-page)
12
+ - [4. Some data](#4-some-data)
13
+ - [5. The event page](#5-the-event-page)
14
+ - [6. Your first component](#6-your-first-component)
15
+ - [7. Taking RSVPs](#7-taking-rsvps)
16
+ - [8. Going live](#8-going-live)
17
+ - [Where to go from here](#where-to-go-from-here)
18
+
19
+ ## 1. Scaffold the project
20
+
21
+ Create a directory with this shape:
22
+
23
+ ```
24
+ rsvp/
25
+ ├── Gemfile
26
+ ├── config.ru
27
+ ├── config/
28
+ │ └── environment.rb
29
+ └── app/
30
+ ├── components/
31
+ ├── data/
32
+ └── pages/
33
+ ```
34
+
35
+ The layout is a convention, not a requirement, but it's the one Weft apps generally follow: `app/pages/` for full-page views, `app/components/` for the interactive pieces that compose into them, and (in our case) `app/data/` for a toy data layer.
36
+
37
+ The `Gemfile`:
38
+
39
+ ```ruby
40
+ source "https://rubygems.org"
41
+
42
+ gem "weft"
43
+ gem "puma" # a web server
44
+ gem "rackup" # the `rackup` command (split out of Rack itself in Rack 3)
45
+ ```
46
+
47
+ Those second two lines matter. Weft runs on Rack, and since Rack 3 the `rackup` command ships as its own gem — without it, `bundle exec rackup` fails with a cryptic `can't find executable rackup for gem rack`. Adding `puma` and `rackup` up front saves you that detour.
48
+
49
+ ```bash
50
+ bundle install
51
+ ```
52
+
53
+ ## 2. Wire up the app
54
+
55
+ `config.ru` is the whole server story — Weft's Router *is* the Rack app:
56
+
57
+ ```ruby
58
+ require_relative "config/environment"
59
+
60
+ run Weft::Router
61
+ ```
62
+
63
+ `config/environment.rb` is where your application loads. Weft doesn't dictate this file, but here's a shape that works:
64
+
65
+ ```ruby
66
+ require "bundler/setup"
67
+ require "weft"
68
+
69
+ APP_ROOT = File.expand_path("..", __dir__)
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
82
+ ```
83
+
84
+ Two things to notice:
85
+
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).)
88
+
89
+ ## 3. Your first page
90
+
91
+ Create `app/pages/events_page.rb`:
92
+
93
+ ```ruby
94
+ class EventsPage < Weft::Page
95
+ def build(attributes = {})
96
+ attributes[:title] = "Upcoming Events"
97
+ super
98
+ h1 "Upcoming Events"
99
+ para "If you can read this in the browser, the app is wired up."
100
+ end
101
+ end
102
+ ```
103
+
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>`.
105
+
106
+ Start the server and have a look:
107
+
108
+ ```bash
109
+ bundle exec rackup
110
+ ```
111
+
112
+ Visit [http://localhost:9292/events](http://localhost:9292/events). You should see the heading and the paragraph.
113
+
114
+ Nobody told Weft about that URL. The route came from the class name: `EventsPage`, minus the `Page` suffix, snake-cased — `/events`. (The suffix is optional; a class named `Events` routes to the same place. See [Routing](routing.md) for the full derivation rules.)
115
+
116
+ > **The `p` gotcha — read this before it costs you an hour.** The one HTML tag you *can't* write the obvious way is the paragraph. Ruby's built-in `Kernel#p` (the debugging printer) shadows the `<p>` builder, so this:
117
+ >
118
+ > ```ruby
119
+ > p "If you can read this in the browser, the app is wired up."
120
+ > ```
121
+ >
122
+ > renders no paragraph at all — and the text goes to your *server terminal* instead, courtesy of `Kernel#p`. Nothing errors; the paragraph is just silently missing. Use **`para`** for paragraphs, always.
123
+
124
+ Two more things worth ten seconds each while the server is up:
125
+
126
+ - 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.
128
+
129
+ ## 4. Some data
130
+
131
+ Weft has no opinions about your data layer — use ActiveRecord, Sequel, an API client, whatever your app needs. For this tutorial, a hash in memory is plenty. Create `app/data/event_store.rb`:
132
+
133
+ ```ruby
134
+ # An in-memory store with a couple of seed events. RSVPs live in a
135
+ # name => answer hash per event. State resets when the server restarts —
136
+ # fine for learning.
137
+ module EventStore
138
+ Event = Struct.new(:id, :name, :date, :location, :rsvps)
139
+
140
+ EVENTS = {
141
+ "summer-bbq" => Event.new(
142
+ "summer-bbq", "Summer BBQ", "Saturday July 18, 4pm", "Riverside Park",
143
+ { "Priya" => "yes" }
144
+ ),
145
+ "trivia-night" => Event.new(
146
+ "trivia-night", "Trivia Night", "Thursday July 23, 7pm", "The Rusty Anchor",
147
+ {}
148
+ )
149
+ }.freeze
150
+
151
+ def self.all = EVENTS.values
152
+ def self.find(id) = EVENTS[id]
153
+ end
154
+ ```
155
+
156
+ And make `EventsPage` list the real events:
157
+
158
+ ```ruby
159
+ class EventsPage < Weft::Page
160
+ def build(attributes = {})
161
+ attributes[:title] = "Upcoming Events"
162
+ super
163
+ h1 "Upcoming Events"
164
+ ul do
165
+ EventStore.all.each do |event|
166
+ li do
167
+ a event.name, href: "/events/#{event.id}"
168
+ text_node " — #{event.date}"
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ ```
175
+
176
+ (`text_node` inserts plain text next to other elements — handy when a line mixes a link and loose text.)
177
+
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.
179
+
180
+ After the restart, `/events` lists both events as links. They 404 — let's fix that.
181
+
182
+ ## 5. The event page
183
+
184
+ Create `app/pages/event_page.rb`:
185
+
186
+ ```ruby
187
+ class EventPage < Weft::Page
188
+ self.page_path = "/events/:event_id"
189
+
190
+ attribute :event_id
191
+
192
+ def build(attributes = {})
193
+ event = EventStore.find(attributes[:event_id])
194
+ raise Weft::NotFound, "no event called #{attributes[:event_id]}" unless event
195
+
196
+ attributes[:title] = event.name
197
+ super
198
+ h1 event.name
199
+ para "#{event.date} — #{event.location}"
200
+ a "← All events", href: "/events"
201
+ end
202
+ end
203
+ ```
204
+
205
+ Restart (new file), then click through to an event. Two new ideas here:
206
+
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.
208
+
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.
210
+
211
+ **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
+
213
+ ## 6. Your first component
214
+
215
+ Pages are destinations; **components** are the composable, interactive pieces inside them. Create `app/components/attendee_list.rb`:
216
+
217
+ ```ruby
218
+ class AttendeeList < Weft::Component
219
+ builder_method :attendee_list
220
+
221
+ attribute :event_id
222
+
223
+ def build(attributes = {})
224
+ super
225
+ event = EventStore.find(attrs.event_id)
226
+ h2 "Who's coming"
227
+ if event.rsvps.empty?
228
+ para "No RSVPs yet. Be the first!"
229
+ else
230
+ ul do
231
+ event.rsvps.each do |name, answer|
232
+ li "#{name} — #{answer}"
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ `builder_method :attendee_list` is the composition idiom: it makes `attendee_list(...)` available as a builder inside any other `build`, just like `h1` and `ul`. Declare one on every component — it's how components nest naturally.
241
+
242
+ Add it to `EventPage`, before the back-link:
243
+
244
+ ```ruby
245
+ attendee_list(event_id: event.id)
246
+ ```
247
+
248
+ Restart, and the Summer BBQ page shows Priya under "Who's coming".
249
+
250
+ View the page source and look at the wrapper Weft rendered:
251
+
252
+ ```html
253
+ <div id="attendee-list-summer-bbq">
254
+ ```
255
+
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.)
257
+
258
+ One more thing, and it's the heart of Weft. Your component isn't just markup inside the page — it's independently addressable:
259
+
260
+ ```bash
261
+ curl "http://localhost:9292/_components/attendee_list?event_id=summer-bbq"
262
+ ```
263
+
264
+ That returns the component alone, as an HTML fragment, rendered fresh. Weft routed it automatically, the same way it routed your pages. Everything in the rest of this tutorial — form submissions, live updates — is machinery that fetches components like this and swaps them into the page. See [Routing](routing.md) for the details.
265
+
266
+ ## 7. Taking RSVPs
267
+
268
+ Now the interactive part. Create `app/components/rsvp_form.rb`:
269
+
270
+ ```ruby
271
+ class RSVPForm < Weft::Component
272
+ builder_method :rsvp_form
273
+
274
+ attribute :event_id
275
+ attribute :name
276
+ attribute :answer
277
+ attribute :error_message
278
+
279
+ includes AttendeeList
280
+
281
+ performs :submit do |attrs|
282
+ event = EventStore.find(attrs.event_id)
283
+ name = attrs.name.to_s.strip
284
+ raise Weft::Unprocessable, "Please tell us your name." if name.empty?
285
+
286
+ event.rsvps[name] = attrs.answer
287
+ nil
288
+ end
289
+
290
+ recovers from: Weft::Unprocessable do |_attrs, error|
291
+ { error_message: error.message }
292
+ end
293
+
294
+ def build(attributes = {})
295
+ super
296
+ h2 "RSVP"
297
+ para(attrs.error_message, style: "color:#b91c1c") if attrs.error_message
298
+ form(action: :submit) do
299
+ input(type: "hidden", name: "event_id", value: attrs.event_id)
300
+ label("Your name: ", for: "name")
301
+ input(type: "text", name: "name", id: "name")
302
+ label(" Coming? ", for: "answer")
303
+ select(name: "answer", id: "answer") do
304
+ %w[yes no maybe].each { |ans| option(ans, value: ans) }
305
+ end
306
+ input(type: "submit", value: "Send RSVP")
307
+ end
308
+ end
309
+ end
310
+ ```
311
+
312
+ Add it to `EventPage` above the attendee list:
313
+
314
+ ```ruby
315
+ rsvp_form(event_id: event.id)
316
+ attendee_list(event_id: event.id)
317
+ ```
318
+
319
+ 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
+
321
+ That's a lot from one class. Unpacking it:
322
+
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.
324
+
325
+ **`form(action: :submit)` wires the form to the action.** Look at the rendered HTML:
326
+
327
+ ```html
328
+ <form hx-post="/_components/rsvp_form/submit" hx-target="#rsvp-form-trivia-night"
329
+ hx-swap="outerHTML" action="/_components/rsvp_form/submit" method="post">
330
+ ```
331
+
332
+ 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
+
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.
335
+
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.
337
+
338
+ **`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
+
340
+ ## 8. Going live
341
+
342
+ The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the attribute:
343
+
344
+ ```ruby
345
+ refreshes every: 10
346
+ ```
347
+
348
+ This edit hot-reloads — no restart. Open the same event in two browser windows, RSVP in one, and within ten seconds the other window's list catches up. The component now polls its own URL (the one you curled in step 6) and swaps itself:
349
+
350
+ ```html
351
+ <div id="attendee-list-summer-bbq"
352
+ hx-get="/_components/attendee_list?event_id=summer-bbq"
353
+ hx-trigger="every 10s" hx-swap="outerHTML">
354
+ ```
355
+
356
+ Declared once on the class, the behavior is present in the initial page render *and* in every refreshed fragment, so it keeps polling forever. Polling is the simplest live-update verb; `pushes` gives you server-sent events with the same one-line flavor ([The Weft DSL](dsl.md#pushes--the-server-sends-updates)).
357
+
358
+ ## Where to go from here
359
+
360
+ 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
+
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.
363
+
364
+ **A finishing touch:** the events list living at `/events` leaves `/` as a 404. Give `EventsPage` an explicit home: `self.page_path = "/"`.
365
+
366
+ **The reference docs**, when you want the full picture:
367
+
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.
369
+ - [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
+ - [Routing](routing.md) — how paths derive, what's routable, collision detection.
371
+ - [Error handling](error-handling.md) — the error family, recovery chains, branding your error pages.
372
+ - [Configuration](configuration.md) — every setting, including production concerns like static assets and quieter error pages.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Weft
6
+ # Metadata for a single component action (declared via `performs` or `transfers`).
7
+ # Knows how to derive its route path and generate htmx attributes.
8
+ #
9
+ # Every Action knows what it `renders` — the component class rendered in the
10
+ # response. For `performs`, that's the declaring class (re-render self).
11
+ # For `transfers`, it's the `to:` class (render something else).
12
+ # The Router always renders `action.renders` — no branching.
13
+ #
14
+ # `target` is the other kind of target — the CSS selector for where the
15
+ # response lands in the DOM (hx-target). Nil means the component's own element.
16
+ class Action
17
+ attr_reader :name, :method, :swap, :target, :renders, :callable
18
+
19
+ def initialize(name:, renders:, method: :post, swap: :outer_html, target: nil, callable: nil) # rubocop:disable Metrics/ParameterLists
20
+ @name = name
21
+ @method = method
22
+ @swap = swap
23
+ @target = target
24
+ @renders = renders
25
+ @callable = callable
26
+ end
27
+
28
+ SWAP_VALUES = {
29
+ # Semantic names (preferred)
30
+ replace: "outerHTML",
31
+ fill: "innerHTML",
32
+ before: "beforebegin",
33
+ after: "afterend",
34
+ append: "beforeend",
35
+ prepend: "afterbegin",
36
+ remove: "delete",
37
+ # htmx-native names (also accepted)
38
+ outer_html: "outerHTML",
39
+ inner_html: "innerHTML",
40
+ before_begin: "beforebegin",
41
+ after_begin: "afterbegin",
42
+ before_end: "beforeend",
43
+ after_end: "afterend",
44
+ delete: "delete",
45
+ none: "none"
46
+ }.freeze
47
+
48
+ TRIGGER_VALUES = {
49
+ click: "click",
50
+ hover: "mouseenter once",
51
+ visible: "revealed",
52
+ input: "input changed delay:300ms"
53
+ }.freeze
54
+
55
+ def nameless? = @name.nil?
56
+
57
+ # The URL path for this action, given the component's base path.
58
+ def route_path(component_path)
59
+ nameless? ? component_path : "#{component_path}/#{name}"
60
+ end
61
+
62
+ # Generate htmx attributes for an element that triggers this action.
63
+ def to_htmx_attrs(component)
64
+ path = route_path(component.class.resolved_component_path)
65
+ {
66
+ "hx-#{method}" => path,
67
+ "hx-target" => target || "##{component.weft_id}",
68
+ "hx-swap" => self.class.resolve_swap(swap),
69
+ "hx-vals" => component.attrs.to_h.to_json
70
+ }
71
+ end
72
+
73
+ # Resolve a swap symbol or string to its htmx value.
74
+ def self.resolve_swap(value)
75
+ SWAP_VALUES.fetch(value, value.to_s)
76
+ end
77
+
78
+ # Resolve a trigger symbol or string to its htmx value.
79
+ def self.resolve_trigger(value)
80
+ TRIGGER_VALUES.fetch(value, value.to_s)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ # Value object representing a component's resolved wire attributes.
5
+ # Provides method-style access with a clear collision-resolution rule:
6
+ # declared attribute names win, then the underlying Hash API is available
7
+ # for any name not declared as an attribute.
8
+ #
9
+ # Action callables receive a ready-made instance (the sole argument to a
10
+ # +performs+/+transfers+ block); you don't construct these yourself:
11
+ #
12
+ # attrs.status # => "shipped" (declared attribute)
13
+ # attrs.count # => 42 (declared attribute — wins over Hash#count)
14
+ # attrs[:status] # => "shipped" (explicit hash access)
15
+ # attrs.select { ... } # delegates to the underlying hash
16
+ # attrs.to_h # => the underlying hash (explicit escape hatch)
17
+ class Attributes
18
+ # Build an Attributes instance by extracting declared keys from a raw
19
+ # attributes hash, applying defaults for missing keys. Does not mutate
20
+ # the raw hash.
21
+ #
22
+ # schema = { status: { default: "pending" }, count: { default: 0 } }
23
+ # raw = { status: "shipped", class: "big" }
24
+ # Weft::Attributes.extract_from(raw, using: schema)
25
+ # # => Attributes{ status: "shipped", count: 0 }
26
+ def self.extract_from(raw, using:)
27
+ data = using.to_h do |name, meta|
28
+ [name, raw.fetch(name, meta[:default])]
29
+ end
30
+ new(data)
31
+ end
32
+
33
+ # @api private
34
+ # Constructed internally (see {.extract_from} and the Router's resolver).
35
+ def initialize(data)
36
+ @data = data
37
+ end
38
+
39
+ def [](key)
40
+ @data[key]
41
+ end
42
+
43
+ def key?(key)
44
+ @data.key?(key)
45
+ end
46
+
47
+ def to_h
48
+ @data
49
+ end
50
+
51
+ def respond_to_missing?(name, include_private = false)
52
+ @data.key?(name) || @data.respond_to?(name, include_private) || super
53
+ end
54
+
55
+ def method_missing(name, *args, **kwargs, &block)
56
+ if @data.key?(name) && args.empty? && kwargs.empty? && !block
57
+ @data[name]
58
+ elsif @data.respond_to?(name)
59
+ @data.public_send(name, *args, **kwargs, &block)
60
+ else
61
+ super
62
+ end
63
+ end
64
+ end
65
+ end