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
@@ -1,33 +1,218 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/context"
4
+ require "weft/dsl/sandbox"
5
+ require "weft/params/assembly"
6
+
3
7
  module Weft
4
8
  class Router
5
- # OOB-include slice of the Router. Renders sibling components declared
6
- # via `includes` alongside an action response or SSE push, with the
7
- # `hx-swap-oob` attribute set so htmx swaps each into its own DOM slot.
9
+ # OOB-include slice of the Router. Renders companion components declared
10
+ # via `includes` alongside a response, with the `hx-swap-oob` attribute
11
+ # set so htmx swaps each into its own DOM slot.
8
12
  #
9
- # Depends on Router internals: `resolver`, `filtered_params`,
10
- # `build_component_with_attrs`.
13
+ # Depends on Router internals: `filtered_params`,
14
+ # `build_component_with_wire`, and the Errors slice's identity and
15
+ # recovery helpers (`unbuilt_instance`, `resolved_dom_id`,
16
+ # `invoke_recovery_block`, `auto_param_overlay`, `component_tag_for`,
17
+ # `compute_retry_url`, `recovery_status`).
11
18
  module OOBIncludes
12
19
  private
13
20
 
14
- # Render OOB-swapped components declared via `includes`.
15
- # Filters by action_name when inclusions declare `on:`.
16
- def render_oob_includes(component_class, primary_attrs, action_name: nil)
17
- applicable = component_class.inclusions.select do |inc|
18
- inc[:on].nil? || inc[:on] == action_name
21
+ # The companions riding an SSE frame. A push is nobody's action and no
22
+ # transfer's destination, so only unfiltered inclusions "every
23
+ # response I render in" — qualify; `on:` and `when:` both name contexts
24
+ # a stream never enters.
25
+ def render_push_companions(component_class, primary_params, render_env:, slots:)
26
+ render_companions(applicable_inclusions(component_class, :push, nil).
27
+ map { |inc| [inc, primary_params, render_env] }, slots)
28
+ end
29
+
30
+ # The inclusions of one component that fire in one context.
31
+ def applicable_inclusions(component_class, context, action_name)
32
+ component_class.inclusions.select { |inc| inclusion_applies?(inc, context, action_name) }
33
+ end
34
+
35
+ # The inclusions that name an action outright. A transfer's declaring
36
+ # component never renders, so its unfiltered inclusions ("every
37
+ # response I render in") stay silent — but `on:` doesn't narrow that
38
+ # default, it replaces it, and this action is the declarer's own.
39
+ def explicitly_named_inclusions(component_class, action_name)
40
+ component_class.inclusions.select { |inc| inc[:on]&.include?(action_name) }
41
+ end
42
+
43
+ # Render a planned set of companions: each entry pairs an inclusion
44
+ # with the params view its block reads and the environment its
45
+ # component builds in, because companions arriving from different
46
+ # branches of one response fork at different points.
47
+ def render_companions(plan, slots)
48
+ return "" if plan.empty?
49
+
50
+ winners = {}
51
+ plan.filter_map { |inclusion, view, env| companion_fragment(inclusion, view, env, slots, winners) }.
52
+ join.html_safe
53
+ end
54
+
55
+ # One companion's fragment, or nil when it yields none. `winners` records
56
+ # who took each slot, so a collision can name the declaration it lost to.
57
+ def companion_fragment(inclusion, view, env, slots, winners)
58
+ fragment = attempt_companion(inclusion, view, env, slots, winners)
59
+ winners[fragment.id] = inclusion if fragment
60
+ fragment
61
+ end
62
+
63
+ # Build a companion, unless its slot is already spoken for.
64
+ #
65
+ # The component abandons its own render the moment it finds the slot
66
+ # taken (Component#claim_dom_slot!) and throws the contested id back
67
+ # here, so a companion that was never going to land doesn't pay for the
68
+ # build. Companions differing in an id-bearing param claim different
69
+ # slots and both ride — which is what makes two of a kind, a left eye
70
+ # and a right eye, a legitimate pair rather than a clash.
71
+ def attempt_companion(inclusion, view, env, slots, winners)
72
+ overlays = companion_overlays(inclusion, view, env)
73
+ component = nil
74
+ # Where Component#claim_dom_slot!'s throw surfaces — one catch per
75
+ # companion, so standing down affects only this one. The block's
76
+ # trailing nil is the no-contest value; the component itself is
77
+ # captured by assignment so a successful build can't read as an id.
78
+ contested = catch(Weft::Context::SLOT_TAKEN) do
79
+ component = build_component_with_wire(inclusion[:component_class], companion_universe(env),
80
+ overlays: overlays, branch_bag: env[:branch_bag],
81
+ slots: slots)
82
+ nil
19
83
  end
20
- return "" if applicable.empty?
84
+ return as_companion(component) unless contested
85
+
86
+ warn_companion_collision(contested, winners[contested], inclusion)
87
+ nil
88
+ rescue StandardError => e
89
+ # A delta block that raised leaves no overlays of its own; the
90
+ # companion falls back to what it inherited from the response.
91
+ recovered_companion(inclusion, env, overlays || env[:overlays] || {}, e)
92
+ end
93
+
94
+ # Each companion is an OOB-delivered child: it renders against the
95
+ # same request universe, branches the primary's bag (rich values
96
+ # included) exactly like a child built in the primary's own build,
97
+ # and layers its own block delta — blockless is an empty delta.
98
+ def companion_overlays(inclusion, view, env)
99
+ inherited = env[:overlays] || {}
100
+ return inherited unless inclusion[:block]
101
+
102
+ delta = Weft::DSL::Sandbox.run(view, &inclusion[:block])
103
+ delta.is_a?(Hash) ? inherited.merge(delta) : inherited
104
+ end
21
105
 
22
- applicable.map { |inc| render_oob_component(inc, primary_attrs) }.join.html_safe
106
+ def companion_universe(env) = env.fetch(:universe) { filtered_params }
107
+
108
+ # A companion is a courtesy, not a contract: the response belongs to
109
+ # the primary, whose render, status and headers a failing bystander
110
+ # must never touch. The action has already committed its side effects
111
+ # by now, so failing the response would misstate what happened. The
112
+ # companion walks its OWN recovery chain into its OWN slot instead —
113
+ # component targets only, because a fragment riding inside a successful
114
+ # response cannot redirect, and a companion must never navigate on the
115
+ # primary's behalf.
116
+ #
117
+ # The recovery renders without the slot register: it inherits the failed
118
+ # companion's claim rather than competing with it, since a build that
119
+ # raised after claiming its slot still holds one.
120
+ def recovered_companion(inclusion, env, overlays, error)
121
+ klass = inclusion[:component_class]
122
+ log_companion_failure(inclusion, error)
123
+ entry = klass.component_recovery_for(error)
124
+ return nil unless entry
125
+
126
+ dom_id = failed_companion_dom_id(klass, env, overlays)
127
+ recovery_overlays = companion_recovery_overlays(klass, companion_state(klass, env, overlays),
128
+ entry, error, dom_id)
129
+ component = build_component_with_wire(klass.resolve_recovery_target(entry),
130
+ companion_universe(env), overlays: recovery_overlays)
131
+ as_companion(claim_dom_id(component, dom_id))
132
+ rescue StandardError => e
133
+ Weft.logger.error("Companion recovery render failed: #{e.class}: #{e.message}")
134
+ nil
23
135
  end
24
136
 
25
- def render_oob_component(inclusion, primary_attrs)
26
- wire_attrs = inclusion[:block] ? inclusion[:block].call(primary_attrs) : filtered_params
27
- resolved = resolver.resolve(inclusion[:component_class], wire_attrs)
28
- component = build_component_with_attrs(inclusion[:component_class], resolved)
137
+ # Identity comes from exactly what the failed render was given — a delta
138
+ # that moves an id-bearing param moves the slot with it, and an error
139
+ # fragment addressed anywhere else lands on the wrong element.
140
+ def failed_companion_dom_id(klass, env, overlays)
141
+ resolved_dom_id(klass, unbuilt_instance(klass, companion_universe(env),
142
+ overlays: overlays, branch_bag: env[:branch_bag]))
143
+ end
144
+
145
+ # The state the failed build was given — its own wire schema over the
146
+ # request universe, the inclusion block's delta on top, branching
147
+ # whatever the primary composed. Rebuilt rather than read off the
148
+ # instance because a build that raised leaves none.
149
+ #
150
+ # Keys the class doesn't declare are laid on afterwards rather than
151
+ # resolved through the stack, which only visits declared keys — that's
152
+ # what keeps an inclusion block's ad-hoc delta readable in the recovery
153
+ # block, the same way it stays readable in the block that produced it.
154
+ def companion_state(klass, env, overlays)
155
+ Weft::Params::Assembly.call(klass, companion_universe(env),
156
+ overlays: overlays, branched_from: env[:branch_bag]).
157
+ overlay(overlays.except(*klass.declared_keys))
158
+ end
159
+
160
+ # The recovery target resolves its own schema from the request universe;
161
+ # the entry's block delta and the auto-injected values ride as overlays,
162
+ # exactly as on the primary's recovery path. No status is set — the
163
+ # response's status belongs to the primary.
164
+ def companion_recovery_overlays(klass, state, entry, error, dom_id)
165
+ component_ctx = { originating_id: dom_id,
166
+ originating_tag: component_tag_for(klass),
167
+ retry_url: compute_retry_url(klass, error_wire_params(klass)),
168
+ status: recovery_status(error, entry) }
169
+ invoke_recovery_block(entry, state, error).merge(auto_param_overlay(error, component_ctx))
170
+ end
171
+
172
+ # The marker telling htmx to swap this fragment into the slot its id
173
+ # names, rather than into the primary's position. The id is already
174
+ # the component's own; only a recovery fragment has to be told.
175
+ def as_companion(component)
29
176
  component.set_attribute("hx-swap-oob", "true")
30
- component.to_s
177
+ component
178
+ end
179
+
180
+ def log_companion_failure(inclusion, error)
181
+ Weft.logger.error(
182
+ "#{inclusion[:component_class].name} companion declared at " \
183
+ "#{inclusion[:source_location].join(':')} failed to render: #{error.class}: #{error.message}"
184
+ )
185
+ end
186
+
187
+ # Names what took the slot: another companion by its declaration site,
188
+ # or — when nothing declared it — the component the response is about.
189
+ def warn_companion_collision(dom_id, kept, dropped)
190
+ claimant = if kept
191
+ "the companion declared at #{kept[:source_location].join(':')}"
192
+ else
193
+ "the component this response renders"
194
+ end
195
+ Weft.logger.warn(
196
+ "#{dropped[:component_class].name} companion declared at " \
197
+ "#{dropped[:source_location].join(':')} was dropped: it resolves to DOM id " \
198
+ "#{dom_id.inspect}, already claimed by #{claimant}. An out-of-band swap is addressed " \
199
+ "by DOM id, so only one fragment can land there — give them different values for an " \
200
+ "identifying param, or drop one of the declarations."
201
+ )
202
+ end
203
+
204
+ def inclusion_applies?(inclusion, context, action_name)
205
+ return true if inclusion[:on].nil? && inclusion[:when].nil?
206
+
207
+ filtered_contexts(inclusion, action_name).include?(context)
208
+ end
209
+
210
+ # The contexts this filtered inclusion fires in — on: and when: union.
211
+ def filtered_contexts(inclusion, action_name)
212
+ contexts = []
213
+ contexts << :action if inclusion[:on]&.include?(action_name)
214
+ contexts << :transfer if inclusion[:when]&.include?(:transferred)
215
+ contexts
31
216
  end
32
217
  end
33
218
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/params/assembly"
4
+ require "weft/resolver"
5
+
3
6
  module Weft
4
7
  class Router
5
8
  # SSE streaming slice of the Router. Handles `/component_path/<stream_suffix>`
@@ -8,9 +11,19 @@ module Weft
8
11
  # connection that emits formatted `event:`/`data:` frames on the
9
12
  # declared cadence.
10
13
  #
11
- # Depends on Router internals: `build_component`, `render_oob_includes`,
12
- # `pass`, `content_type`, `headers`, `stream`.
14
+ # Push failures route through the component's recovers chain (component
15
+ # targets only see Errors#render_push_recovery) and count against an
16
+ # attempts budget; when it runs out, the CLOSE_EVENT frame tells the
17
+ # client to stop reconnecting and the connection closes.
18
+ #
19
+ # Depends on Router internals: `build_component`, `render_push_companions`,
20
+ # `render_push_recovery`, `pass`, `content_type`, `headers`, `stream`.
13
21
  module Streaming
22
+ # SSE event name that tells htmx-ext-sse to close the EventSource and
23
+ # stop reconnecting; every pushing component's wrapper names it in its
24
+ # sse-close attribute (Component#apply_push_attrs).
25
+ CLOSE_EVENT = "weft:close"
26
+
14
27
  private
15
28
 
16
29
  def handle_stream_request(path)
@@ -27,35 +40,88 @@ module Weft
27
40
  def stream_component(component_class)
28
41
  content_type "text/event-stream"
29
42
  headers "Cache-Control" => "no-cache"
30
- interval = component_class.push_config[:every]
31
43
  klass = component_class
32
44
 
33
45
  stream :keep_open do |out|
34
- # New subscribers get an immediate state snapshot, then the regular
35
- # cadencesleep only kicks in from the second frame onward. The flag
36
- # flips before the push (not after a *successful* one) so a persistently
37
- # failing push still throttles on the interval instead of busy-looping.
38
- after_first = false
39
- loop do
40
- sleep interval if after_first
41
- after_first = true
42
- push_component_event(out, klass)
43
- rescue Errno::EPIPE, IOError
44
- break
45
- rescue StandardError => e
46
- Weft.logger.error("SSE push error for #{klass.name}: #{e.message}")
47
- end
46
+ run_push_loop(out, klass)
47
+ # Both exits dead client and exhausted attempts are final, so
48
+ # close explicitly: under :keep_open, merely returning from this
49
+ # block does not end the response (observed on Puma as the block
50
+ # re-running and the countdown restarting on a live connection).
51
+ out.close
52
+ end
53
+ end
54
+
55
+ # New subscribers get an immediate state snapshot, then the regular
56
+ # cadence — sleep only kicks in from the second frame onward — unless
57
+ # the component declared `immediate: false`, which pre-arms the sleep.
58
+ # The flag flips before the push (not after a *successful* one) so a
59
+ # persistently failing push still throttles on the interval instead of
60
+ # busy-looping.
61
+ def run_push_loop(out, klass)
62
+ interval = klass.push_config[:every]
63
+ attempts = klass.push_config[:attempts] || Weft.configuration.push_attempts
64
+ after_first = !klass.push_config.fetch(:immediate, true)
65
+ failures = 0
66
+ loop do
67
+ sleep interval if after_first
68
+ after_first = true
69
+ push_component_event(out, klass)
70
+ failures = 0
71
+ rescue Errno::EPIPE, IOError
72
+ break
73
+ rescue StandardError => e
74
+ failures += 1
75
+ break unless push_failure_frames(out, klass, e, failures: failures, attempts: attempts)
48
76
  end
49
77
  end
50
78
 
51
79
  def push_component_event(out, component_class)
52
- component = build_component(component_class)
53
- html = component.content + render_oob_includes(component_class, component.attrs)
54
- out << format_sse_event(component.weft_id, html)
80
+ slots = Set.new
81
+ component = build_component(component_class, slots: slots)
82
+ env = { universe: filtered_params, branch_bag: component.params }
83
+ html = component.content +
84
+ render_push_companions(component_class, component.params, render_env: env, slots: slots)
85
+ out << format_sse_event(component.weft_dom_id, html)
86
+ end
87
+
88
+ # A failure cycle's frames: the recovery fragment (when the chain yields
89
+ # a component target) and, once the attempts budget is spent, the close
90
+ # event that tells the client to stop reconnecting. Returns false when
91
+ # the stream is done — budget exhausted or the client vanished mid-write.
92
+ def push_failure_frames(out, component_class, error, failures:, attempts:)
93
+ Weft.logger.error("SSE push error for #{component_class.name}: #{error.message}")
94
+ remaining = attempts - failures
95
+ push_recovery_frame(out, component_class, error, remaining)
96
+ return true if remaining.positive?
97
+
98
+ Weft.logger.error(
99
+ "SSE stream for #{component_class.name} closed after #{attempts} consecutive failed pushes"
100
+ )
101
+ out << format_sse_event(CLOSE_EVENT, "")
102
+ false
103
+ rescue Errno::EPIPE, IOError
104
+ false
105
+ end
106
+
107
+ # Resolve, render, write. The event name is recomputed from the class +
108
+ # resolved wire params — the failed build left no instance to ask. Any
109
+ # render-path StandardError is logged and swallowed: the failure already
110
+ # counts against the budget, and the close logic must still run.
111
+ def push_recovery_frame(out, component_class, error, attempts_remaining)
112
+ resolved = Weft::Resolver.resolve(component_class, filtered_params)
113
+ state = Weft::Params::Assembly.for_request(component_class, filtered_params)
114
+ html = render_push_recovery(component_class, state, error, attempts_remaining: attempts_remaining)
115
+ out << format_sse_event(component_class.weft_dom_id_for(resolved), html) if html
116
+ rescue Errno::EPIPE, IOError
117
+ raise
118
+ rescue StandardError => e
119
+ Weft.logger.error("Push recovery render failed: #{e.class}: #{e.message}")
55
120
  end
56
121
 
57
122
  def format_sse_event(event_name, html)
58
123
  sse_data = html.each_line.map { |line| "data: #{line.chomp}" }.join("\n")
124
+ sse_data = "data: " if sse_data.empty? # dataless SSE events are never dispatched
59
125
  "event: #{event_name}\n#{sse_data}\n\n"
60
126
  end
61
127
  end
data/lib/weft/router.rb CHANGED
@@ -63,11 +63,20 @@ module Weft
63
63
  end
64
64
  end
65
65
 
66
- # In standalone mode (no downstream Rack app), Sinatra's not_found
67
- # block fires when no route matched. Walk the Weft::Page recovers chain
68
- # (default Weft::Defaults::NotFoundPage). In middleware mode, `pass`
69
- # falls through to the downstream app this block doesn't fire.
70
- not_found do
66
+ # Both error registrations are exception-keyed, never status-keyed: an
67
+ # error(404)/not_found handler fires on response *status* after every
68
+ # dispatch custom recovery bodies return normally, no exception
69
+ # recorded and would overwrite them with the default chain's output.
70
+ # Exception keys only fire when Sinatra itself raises.
71
+
72
+ # Routing miss in standalone mode: Sinatra raises Sinatra::NotFound
73
+ # after the final `pass` (middleware mode forwards downstream instead).
74
+ # Translate and walk the default Weft::Page chain. The exact-class key
75
+ # is load-bearing: Sinatra dispatches exact keys across all superclasses
76
+ # before walking the exception hierarchy, and Sinatra::Base registers
77
+ # its own development-mode `error NotFound` ("doesn't know this ditty")
78
+ # that must be shadowed here — StandardError below wouldn't be reached.
79
+ error Sinatra::NotFound do
71
80
  content_type :html
72
81
  handle_page_chain_failure(Weft::NotFound.new(request.path),
73
82
  originating_page_class: nil)
@@ -116,10 +125,6 @@ module Weft
116
125
  pass
117
126
  end
118
127
 
119
- def resolver
120
- @resolver ||= Resolver.new
121
- end
122
-
123
128
  def filtered_params
124
129
  params.except("splat", "captures")
125
130
  end
@@ -127,24 +132,28 @@ module Weft
127
132
  # Render a component as HTML. inner: true returns children only
128
133
  # (for SSE innerHTML swap where the wrapper element must persist).
129
134
  def render_component(component_class, inner: false)
130
- resolved_attrs = resolver.resolve(component_class, filtered_params)
131
- component = build_component_with_attrs(component_class, resolved_attrs)
135
+ state = Weft::Params::Assembly.for_request(component_class, filtered_params)
136
+ component = build_component_with_wire(component_class, filtered_params, branch_bag: state)
132
137
  inner ? component.content : component.to_s
133
138
  rescue StandardError => e
134
- render_error(component_class, resolved_attrs || {}, e)
139
+ render_error(component_class, state || Weft::Params.new({}), e)
135
140
  end
136
141
 
137
142
  # Build a component instance from the current request params.
138
- def build_component(component_class)
139
- resolved_attrs = resolver.resolve(component_class, filtered_params)
140
- build_component_with_attrs(component_class, resolved_attrs)
143
+ def build_component(component_class, slots: nil)
144
+ build_component_with_wire(component_class, filtered_params, slots: slots)
141
145
  end
142
146
 
143
- # Build a component instance from pre-resolved attributes.
144
- def build_component_with_attrs(component_class, resolved_attrs)
147
+ # Build a component in a fresh context carrying the wire source; the
148
+ # component resolves its own declared params from it at construction.
149
+ # Arbre's builder attributes stay pure chrome — params travel their own
150
+ # channel. `overlays` carries request-scoped verb-block deltas;
151
+ # `branch_bag` lets the root inherit a primary's bag (OOB companions),
152
+ # and `slots` arbitrates which root gets to claim a DOM id.
153
+ def build_component_with_wire(component_class, wire_params, overlays: {}, branch_bag: nil, slots: nil)
145
154
  klass = component_class
146
- attrs = resolved_attrs
147
- context = Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }
155
+ context = Weft::Context.new({}, nil, wire_params: wire_params, overlays: overlays,
156
+ branch_bag: branch_bag, slots: slots) { insert_tag(klass) }
148
157
  context.children.first
149
158
  end
150
159
 
@@ -155,14 +164,13 @@ module Weft
155
164
  # (B1 / C1 page-context); the gem-default catches StandardError.
156
165
  def render_page(page_class, route_params)
157
166
  merged_params = filtered_params.merge(route_params)
158
- resolved_attrs = resolver.resolve(page_class, merged_params)
159
167
  klass = page_class
160
- attrs = resolved_attrs
161
- Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }.to_s
168
+ Weft::Context.new({}, nil, wire_params: merged_params) { insert_tag(klass) }.to_s
162
169
  rescue StandardError => e
163
170
  handle_page_chain_failure(e,
164
171
  originating_page_class: page_class,
165
- originating_attrs: resolved_attrs || {})
172
+ originating_params: Weft::Params::Assembly.for_request(page_class, merged_params),
173
+ originating_wire: merged_params)
166
174
  end
167
175
 
168
176
  def htmx_request?
@@ -181,8 +189,8 @@ module Weft
181
189
  end
182
190
  end
183
191
 
184
- def apply_trigger_header(component_class)
185
- events = component_class.trigger_events
192
+ def apply_trigger_header(component_class, action_name)
193
+ events = component_class.trigger_events(action_name)
186
194
  return if events.empty?
187
195
 
188
196
  headers["HX-Trigger"] = events.join(", ")
data/lib/weft/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Weft
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/weft.rb CHANGED
@@ -7,9 +7,13 @@ require "active_support/core_ext/integer/time"
7
7
  require "active_support/core_ext/string/inflections"
8
8
  require "arbre"
9
9
 
10
+ require "weft/autoloading"
10
11
  require "weft/error"
11
- require "weft/attributes"
12
- require "weft/dsl/attributes"
12
+ require "weft/context/traversal"
13
+ require "weft/params"
14
+ require "weft/params/assembly"
15
+ require "weft/dsl/params"
16
+ require "weft/dsl/sandbox"
13
17
  require "weft/dsl/recoveries"
14
18
  require "weft/dsl/triggers"
15
19
  require "weft/dsl/inclusions"
@@ -47,30 +51,47 @@ module Weft
47
51
  @configuration ||= Configuration.new
48
52
  end
49
53
 
50
- def registry
51
- @registry ||= Registry.new
52
- end
53
-
54
54
  def configure
55
55
  yield configuration
56
56
  apply_configuration
57
57
  end
58
58
 
59
+ # Set up Zeitwerk-managed autoloading (and, optionally, dev-mode reloading)
60
+ # for the application's own code. The imperative older sibling of
61
+ # {configure}: call it FIRST in your boot file — it builds the loader and
62
+ # eager-loads everything on the spot, so constants from these paths are
63
+ # resolvable by the time a configure block references them.
64
+ #
65
+ # Weft.configure_autoloading(
66
+ # paths: [File.join(APP_ROOT, "app", "components"),
67
+ # File.join(APP_ROOT, "app", "pages")],
68
+ # inflections: { "dropship_ui" => "DropshipUI" },
69
+ # reload: ENV["APP_ENV"] == "development"
70
+ # )
71
+ #
72
+ # With reload: true, code changes take effect without restarting: constants
73
+ # reload on every request, unloaded classes are evicted from the registry
74
+ # the moment Zeitwerk removes them, and class-valued configuration knobs
75
+ # rebind to their fresh definitions. Returns the Zeitwerk::Loader.
76
+ def configure_autoloading(paths:, inflections: {}, reload: false)
77
+ Autoloading.setup(paths: paths, inflections: inflections, reload: reload)
78
+ end
79
+
59
80
  # Convenience wrapper for Weft::Redirect.to.
60
- def redirect(target, **attrs)
61
- Redirect.to(target, **attrs)
81
+ def redirect(target, **params)
82
+ Redirect.to(target, **params)
62
83
  end
63
84
 
64
- # Register a named interaction shorthand. Delegates to Weft::Shorthands.
85
+ # Register a named interaction preset. Delegates to Weft::Presets.
65
86
  #
66
- # Weft.register_shorthand :tooltip, trigger: :hover, swap: :fill
67
- def register_shorthand(name, **defaults)
68
- Shorthands.register(name, **defaults)
87
+ # Weft.register_preset :tooltip, trigger: :hover, swap: :fill
88
+ def register_preset(name, **defaults)
89
+ Presets.register(name, **defaults)
69
90
  end
70
91
 
71
- # Look up a registered shorthand by name. Delegates to Weft::Shorthands.
72
- def shorthand(name)
73
- Shorthands.lookup(name)
92
+ # Look up a registered preset by name. Delegates to Weft::Presets.
93
+ def preset(name)
94
+ Presets.lookup(name)
74
95
  end
75
96
 
76
97
  private
@@ -81,17 +102,9 @@ module Weft
81
102
  def apply_configuration
82
103
  logger.level = configuration.resolved_log_level
83
104
  Router.set(:logging, configuration.router_logging)
84
- enable_auto_reload! if configuration.auto_reload && !@auto_reload_applied
85
105
  apply_static_assets!
86
106
  end
87
107
 
88
- def enable_auto_reload!
89
- require "sinatra/reloader"
90
- Router.register(Sinatra::Reloader)
91
- configuration.reload_paths.each { |path| Router.also_reload(path) }
92
- @auto_reload_applied = true
93
- end
94
-
95
108
  def apply_static_assets!
96
109
  @mounted_static_bundles ||= Set.new
97
110
  configuration.static_assets.each do |name, bundle|
@@ -120,5 +133,5 @@ module Weft
120
133
  end
121
134
  end
122
135
 
123
- require "weft/shorthands"
136
+ require "weft/presets"
124
137
  require "weft/defaults"