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
@@ -12,12 +12,12 @@ module Weft
12
12
  module ClassMethods
13
13
  # Declare a user-initiated action on this component.
14
14
  #
15
- # performs :advance do |attrs|
16
- # order = Order.find(attrs.order_id)
15
+ # performs :advance do |params|
16
+ # order = Order.find(params.order_id)
17
17
  # Oms::PrepareOrder.call(order)
18
18
  # end
19
19
  #
20
- # performs method: :delete, swap: :delete do |attrs| ... end
20
+ # performs method: :delete, swap: :delete do |params| ... end
21
21
  def performs(name = nil, method: :post, swap: :outer_html, target: nil, &block)
22
22
  action = Weft::Action.new(name: name, method: method, swap: swap,
23
23
  target: target, renders: self, callable: block)
@@ -26,19 +26,19 @@ module Weft
26
26
 
27
27
  # Sugar for performs with swap: :delete. Removes the component from
28
28
  # the DOM on success. The callable (if given) runs for side effects;
29
- # the return value is rendered but htmx ignores the response body.
29
+ # the success response carries no body (OOB includes still ride).
30
30
  #
31
31
  # dismisses :close # no side effects
32
- # dismisses :remove do |attrs| # with side effects
33
- # Item.find(attrs.item_id).archive!
32
+ # dismisses :remove do |params| # with side effects
33
+ # Item.find(params.item_id).archive!
34
34
  # end
35
- def dismisses(name = nil, method: :delete, &)
36
- performs(name, method: method, swap: :delete, &)
35
+ def dismisses(name = nil, method: :delete, target: nil, &)
36
+ performs(name, method: method, swap: :delete, target: target, &)
37
37
  end
38
38
 
39
39
  # Declare a transfer — an action that renders a different component.
40
40
  #
41
- # transfers :edit, to: EditableOrderHeader do |attrs|
41
+ # transfers :edit, to: EditableOrderHeader do |params|
42
42
  # { mode: "full" }
43
43
  # end
44
44
  def transfers(name = nil, to:, method: :post, swap: :outer_html, target: nil, &block)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/error"
4
+
3
5
  module Weft
4
6
  module DSL
5
7
  # Mixin for classes that declare OOB-swapped sibling components.
@@ -12,21 +14,42 @@ module Weft
12
14
  end
13
15
 
14
16
  module ClassMethods
17
+ # Contexts a `when:` filter may name. Only :transferred ships today;
18
+ # the vocabulary grows deliberately.
19
+ WHEN_VALUES = %i[transferred].freeze
20
+
15
21
  # Declare that another component should be OOB-swapped alongside
16
- # this component's action responses and SSE pushes.
22
+ # this component's responses.
17
23
  #
18
- # includes OrderHeader # pass-through attrs
19
- # includes OrderHeader, on: :advance # only on :advance action
20
- # includes OrderHeader do |attrs| # explicit attr mapping
21
- # { order_id: attrs.order_id, compact: true }
24
+ # includes OrderHeader # every response
25
+ # includes OrderHeader, on: :advance # own action(s) only
26
+ # includes OrderHeader, on: %i[advance retreat]
27
+ # includes OrderHeader, when: :transferred # transfer arrivals only
28
+ # includes OrderHeader, on: :save, when: :transferred # union: either
29
+ # includes OrderHeader do |params| # companion delta
30
+ # { order_id: params.order_id, compact: true }
22
31
  # end
23
32
  #
24
- # Without a block, the included component resolves from the same
25
- # request params as the primary component. With a block, the block
26
- # receives the primary component's resolved attrs and returns wire
27
- # attrs for the included component's Resolver.
28
- def includes(component_class, on: nil, &block)
29
- own_inclusions << { component_class: component_class, on: on, block: block }
33
+ # Unfiltered inclusions ride every response this component renders in:
34
+ # its own actions, SSE pushes, and transfer arrivals. Filters
35
+ # enumerate contexts `on:` names this component's OWN actions
36
+ # (never a transferring component's), `when: :transferred` fires when
37
+ # this component renders as a transfer target; declaring both is a
38
+ # union. The block's return is a DELTA overlaid on the request for
39
+ # this companion alone; blockless is exactly an empty delta.
40
+ #
41
+ # (`when` rides **options because it's a Ruby reserved word — call
42
+ # sites are unaffected, only this signature is.)
43
+ def includes(component_class, on: nil, **options, &block)
44
+ when_filter = options.delete(:when)
45
+ raise ArgumentError, "unknown keywords: #{options.keys.inspect}" unless options.empty?
46
+
47
+ site = caller_locations(1, 1).first
48
+ own_inclusions << { component_class: component_class,
49
+ on: on.nil? ? nil : Array(on),
50
+ when: validated_when(when_filter),
51
+ block: block,
52
+ source_location: [site.path, site.lineno] }
30
53
  end
31
54
 
32
55
  # All declared inclusions (own + inherited).
@@ -40,6 +63,20 @@ module Weft
40
63
 
41
64
  private
42
65
 
66
+ def validated_when(value)
67
+ return nil if value.nil?
68
+
69
+ contexts = Array(value)
70
+ unknown = contexts - WHEN_VALUES
71
+ unless unknown.empty?
72
+ raise Weft::InvalidDefinition,
73
+ "includes when: #{unknown.map(&:inspect).join(', ')} names no known context — " \
74
+ "recognized: #{WHEN_VALUES.map(&:inspect).join(', ')}"
75
+ end
76
+
77
+ contexts
78
+ end
79
+
43
80
  def own_inclusions
44
81
  @own_inclusions ||= []
45
82
  end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/error"
4
+ require "weft/params/assembly"
5
+
6
+ module Weft
7
+ module DSL
8
+ # Mixin for classes that declare consumed inputs — the doors into `params`.
9
+ # Provides the `param` (wire), `receives` (caller hand-off), and `derives`
10
+ # (lazy server-side derivation) class DSL and the `params` instance reader.
11
+ # Used by Component (for partial route params) and Page (for page route params).
12
+ module Params
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ end
16
+
17
+ module ClassMethods
18
+ # Declare a wire param. `default:` fills the key when no source
19
+ # supplies it; `type:` coerces the wire's string into the declared
20
+ # type (see Resolver::TYPES). The two are orthogonal — an untyped
21
+ # param accepts any value uncoerced — but a non-nil default must
22
+ # already be an instance of the declared type.
23
+ # param :page, type: :integer
24
+ # param :status, default: "active", type: :string
25
+ def param(name, default: nil, type: nil)
26
+ validate_type!(name, type, default) unless type.nil?
27
+ meta = { default: default }
28
+ meta[:type] = type unless type.nil?
29
+ own_params[name] = meta
30
+ end
31
+
32
+ # Returns all declared params (own + inherited), preserving declaration order.
33
+ def params
34
+ if superclass.respond_to?(:params)
35
+ superclass.params.merge(own_params)
36
+ else
37
+ own_params.dup
38
+ end
39
+ end
40
+
41
+ # Declare a hand-off param: the caller provides the value as a builder
42
+ # kwarg at the call site; it lands in `params`, never in HTML chrome.
43
+ # receives :order # required — absence raises
44
+ # receives :page_num, default: 1 # optional — any declared default
45
+ # # (even nil) softens absence
46
+ # Hand-offs are server-side values: they never serialize into URLs and
47
+ # don't make a component routable.
48
+ def receives(name, **options)
49
+ meta = {}
50
+ meta[:default] = options[:default] if options.key?(:default)
51
+ own_received_params[name] = meta
52
+ end
53
+
54
+ # All declared hand-offs (own + inherited), preserving declaration
55
+ # order. Kept separate from `params` — the wire door and the hand-off
56
+ # door differ in serialization and routability, even for dual keys.
57
+ def received_params
58
+ if superclass.respond_to?(:received_params)
59
+ superclass.received_params.merge(own_received_params)
60
+ else
61
+ own_received_params.dup
62
+ end
63
+ end
64
+
65
+ # Declare a lazy server-side derivation: the block runs (at most once
66
+ # per render) when `params.name` is first read, never if it isn't.
67
+ # derives(:order) { |params| Order.find(params.order_id) }
68
+ # The block is a `(params) -> value` pure function with a void self.
69
+ # Derived values are server-side: never serialized, not
70
+ # routable-making.
71
+ def derives(name, &block)
72
+ unless block
73
+ raise Weft::InvalidDefinition,
74
+ "derives #{name.inspect} requires a block — the derivation is the declaration"
75
+ end
76
+
77
+ own_derived_params[name] = { block: block, source_location: block.source_location }
78
+ end
79
+
80
+ # Sugar for statically-known derivations: each pair registers
81
+ # `derives(key) { value }`. This is just `derives` — identical
82
+ # priority, overridability, and laziness; only the value is fixed at
83
+ # declaration. For anything computed per render (queries, clocks),
84
+ # use `derives` — an interpolated value here would freeze at
85
+ # class-load time.
86
+ # defines label: "Drivers", accent: "available"
87
+ def defines(pairs)
88
+ site = caller_locations(1, 1).first
89
+ pairs.each do |name, value|
90
+ own_derived_params[name] = { block: proc { |_p| value },
91
+ source_location: [site.path, site.lineno] }
92
+ end
93
+ end
94
+
95
+ # Every key this class declares, whichever door it came through —
96
+ # what a bag assembled for this class will hold entries for.
97
+ def declared_keys
98
+ params.keys | received_params.keys | derived_params.keys
99
+ end
100
+
101
+ # All declared derivations (own + inherited), preserving declaration
102
+ # order. A child redeclaring a parent's key replaces the block, like
103
+ # a method override.
104
+ def derived_params
105
+ if superclass.respond_to?(:derived_params)
106
+ superclass.derived_params.merge(own_derived_params)
107
+ else
108
+ own_derived_params.dup
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def validate_type!(name, type, default)
115
+ entry = Weft::Resolver::TYPES[type]
116
+ unless entry
117
+ raise Weft::InvalidDefinition,
118
+ "param #{name.inspect} declares unknown type #{type.inspect} — declarable " \
119
+ "types are #{Weft::Resolver::TYPES.keys.map(&:inspect).join(', ')}"
120
+ end
121
+ return if default.nil? || entry[:classes].any? { |klass| default.is_a?(klass) }
122
+
123
+ raise Weft::InvalidDefinition,
124
+ "param #{name.inspect} declares type #{type.inspect} but its default " \
125
+ "#{default.inspect} is #{default.class} — make them agree, or drop one"
126
+ end
127
+
128
+ def own_params
129
+ @own_params ||= {}
130
+ end
131
+
132
+ def own_received_params
133
+ @own_received_params ||= {}
134
+ end
135
+
136
+ def own_derived_params
137
+ @own_derived_params ||= {}
138
+ end
139
+ end
140
+
141
+ # One-time chrome-collision warnings, keyed [class, key].
142
+ # See #warn_declared_chrome_collisions.
143
+ def self.warned_collisions
144
+ @warned_collisions ||= Set.new
145
+ end
146
+
147
+ # Instance access to the resolved bag.
148
+ # Returns a Weft::Params object with method-style and hash access.
149
+ attr_reader :params
150
+
151
+ # @api private
152
+ # The bag projected onto this class's own declared wire schema — the
153
+ # only slice that serializes (refresh/stream URLs, DOM ids, hx-vals).
154
+ # Hand-offs and inherited values are server-side and never ride the
155
+ # wire. Per-key reads, NOT to_h: serialization must never materialize
156
+ # non-wire derivations (a thunk on a wire-schema key — the rare
157
+ # param+derives dual — does force here; the refresh contract wins).
158
+ def serializable_params
159
+ return {} unless params
160
+
161
+ self.class.params.keys.to_h { |key| [key, params[key]] }
162
+ end
163
+
164
+ private
165
+
166
+ # Assemble the bag per the source stack: staged hand-off > own wire
167
+ # value > inherited bag value > declared default. Staging only happens
168
+ # under Weft::Context; in a plain Arbre context the hand-off door is a
169
+ # build-top fallback instead, so hand-off validation waits for it there.
170
+ def assembled_params
171
+ if arbre_context.respond_to?(:take_received!)
172
+ resolve_bag(received: arbre_context.take_received!(self.class) || {}, validate: true)
173
+ else
174
+ resolve_bag(received: {}, validate: false)
175
+ end
176
+ end
177
+
178
+ def resolve_bag(received:, validate:)
179
+ bag = Weft::Params::Assembly.call(self.class, wire_source,
180
+ hand_offs: received,
181
+ overlays: context_overlays, branched_from: inherited_bag)
182
+ validate_hand_offs!(bag) if validate
183
+ bag
184
+ end
185
+
186
+ def context_overlays
187
+ arbre_context.respond_to?(:overlays) ? arbre_context.overlays : {}
188
+ end
189
+
190
+ # Checks required_hand_off? before reading the key: a required hand-off
191
+ # is receives-only (never thunked), so the read can't force anything —
192
+ # and dual keys short-circuit without touching their lazy derivation.
193
+ def validate_hand_offs!(bag)
194
+ self.class.received_params.each_key do |key|
195
+ raise_not_received!(key) if required_hand_off?(key) && bag[key].nil?
196
+ end
197
+ end
198
+
199
+ def wire_source
200
+ arbre_context.respond_to?(:wire_params) ? arbre_context.wire_params : {}
201
+ end
202
+
203
+ # Branch a copy of the nearest tree-ancestor's bag — the in-page
204
+ # parent-child axis: a component sees everything above it, nothing
205
+ # beside it. At construction the current element IS the future parent,
206
+ # so the walk works before the tree links this instance in. Returns
207
+ # [data, provenance]; the copy is thunk-preserving (never forces the
208
+ # ancestor's lazy entries) and nil-dropping. A root with no tree
209
+ # ancestor falls back to the context's branch bag — how an OOB
210
+ # companion inherits from the primary it rides alongside, and how the
211
+ # state a request has already composed reaches the component it renders.
212
+ def inherited_bag
213
+ el = arbre_context.current_arbre_element
214
+ while el
215
+ return el.params if el.is_a?(Weft::DSL::Params) && el.params
216
+
217
+ el = el.parent
218
+ end
219
+ arbre_context.respond_to?(:branch_bag) ? arbre_context.branch_bag : nil
220
+ end
221
+
222
+ # A hand-off is required when `receives` is its only door and no
223
+ # default was declared — nothing else can satisfy the presumption.
224
+ def required_hand_off?(key)
225
+ meta = self.class.received_params[key]
226
+ meta && !meta.key?(:default) && !self.class.params.key?(key)
227
+ end
228
+
229
+ def raise_not_received!(key)
230
+ raise Weft::NotReceived,
231
+ "#{self.class.name} expects to receive #{key.inspect}: pass it as a builder kwarg " \
232
+ "at the call site, or declare a default: to make it optional"
233
+ end
234
+
235
+ # Build-top fallback for the hand-off door in plain Arbre contexts,
236
+ # where interception never runs: pull receives-declared kwargs out of
237
+ # the attributes hash (they're hand-offs, not chrome), overlay them on
238
+ # the bag, and run the validation construction had to defer. Handed
239
+ # nil counts as absence, like everywhere else in the stack.
240
+ def apply_received_fallback(attributes)
241
+ keys = self.class.received_params.keys & attributes.keys
242
+ handed = keys.to_h { |k| [k, attributes.delete(k)] }
243
+ @params = @params.overlay(handed.compact)
244
+ validate_hand_offs!(@params)
245
+ end
246
+
247
+ # A builder kwarg naming a declared param renders as an HTML attribute
248
+ # only (params arrive from the wire, not the call site). Warn once per
249
+ # (class, key): param names legitimately collide with HTML attribute
250
+ # names (height, title, size, ...), so a standing collision shouldn't
251
+ # spam every render. Set#add? races just double-warn; harmless.
252
+ def warn_declared_chrome_collisions(attributes)
253
+ attributes.each_key do |key|
254
+ next unless self.class.params.key?(key)
255
+ next unless Weft::DSL::Params.warned_collisions.add?([self.class, key])
256
+
257
+ Weft.logger.warn(
258
+ "#{self.class.name}: builder kwarg #{key.inspect} matches a declared param and " \
259
+ "renders as an HTML attribute only (params arrive from the wire, not the call site)"
260
+ )
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -15,22 +15,27 @@ module Weft
15
15
  module ClassMethods
16
16
  # Declare a recovery edge: how this class handles a specific error.
17
17
  #
18
- # recovers from: Weft::Unprocessable do |attrs, error|
18
+ # recovers from: Weft::Unprocessable do |params, error|
19
19
  # { error_messages: error.messages }
20
20
  # end
21
21
  #
22
22
  # recovers from: Weft::Unauthorized, with: LoginPage
23
+ # recovers from: ActiveRecord::RecordNotFound, with: NotFoundPage, status: 404
23
24
  #
24
25
  # `from:` accepts a Class (subclass-inclusive), Integer (matched against
25
26
  # HTTPError#status), Range, or Array of any of the above.
26
27
  # `with:` accepts a Class (Page or Component) or Symbol (resolved against
27
28
  # Weft.configuration at error-handling time). Default: self.
28
- # The optional block receives `|attrs, error|` and returns a hash of
29
- # additional attrs that merge with the original on the recovery edge.
30
- # Symmetric with performs/transfers contracts (attrs first; error is the
29
+ # `status:` declares what the error means on the wire — recoveries from
30
+ # errors that aren't Weft::HTTPErrors report 500 without it. Must be an
31
+ # HTTP error status (400..599); raises Weft::InvalidUsage otherwise.
32
+ # The optional block receives `|params, error|` and returns a hash of
33
+ # additional params that merge with the original on the recovery edge.
34
+ # Symmetric with performs/transfers contracts (params first; error is the
31
35
  # recovery-specific extra). The block never returns HTML.
32
- def recovers(from:, with: nil, &block)
33
- own_recoveries << { from: from, with: with, block: block }
36
+ def recovers(from:, with: nil, status: nil, &block)
37
+ validate_recovery_status!(status)
38
+ own_recoveries << { from: from, with: with, status: status, block: block }
34
39
  end
35
40
 
36
41
  # All declared recovery entries (own + inherited), in resolution order.
@@ -53,6 +58,18 @@ module Weft
53
58
  recoveries.find { |entry| recovery_matches?(entry[:from], exception) }
54
59
  end
55
60
 
61
+ # Like recovery_for, but skips entries whose target resolves to a Page —
62
+ # for contexts that can only render component fragments (an SSE push
63
+ # can't redirect or full-page swap). The gem-default StandardError
64
+ # entry at the bottom of every component chain resolves to a component,
65
+ # so StandardError-family exceptions always find a match.
66
+ def component_recovery_for(exception)
67
+ recoveries.find do |entry|
68
+ recovery_matches?(entry[:from], exception) &&
69
+ !page_recovery_target?(resolve_recovery_target(entry))
70
+ end
71
+ end
72
+
56
73
  # Resolve the recovery entry's `with:` value to a concrete target class.
57
74
  # Symbol values look up `Weft.configuration.<sym>` (resolved at error-handling
58
75
  # time so config reassignment propagates). Nil falls back to self.
@@ -70,6 +87,15 @@ module Weft
70
87
  @own_recoveries ||= []
71
88
  end
72
89
 
90
+ # Declarations raise (a bad status is a coding bug, visible at load);
91
+ # only error semantics are assignable — a recovery can't claim success.
92
+ def validate_recovery_status!(status)
93
+ return if status.nil? || (status.is_a?(Integer) && (400..599).cover?(status))
94
+
95
+ raise Weft::InvalidUsage,
96
+ "recovers status: must be an HTTP error status (400..599); got #{status.inspect}"
97
+ end
98
+
73
99
  def recovery_matches?(from_clause, exception)
74
100
  case from_clause
75
101
  when Array then from_clause.any? { |f| recovery_matches?(f, exception) }
@@ -83,6 +109,10 @@ module Weft
83
109
  def recovery_status_of(exception)
84
110
  exception.is_a?(Weft::HTTPError) ? exception.status : 500
85
111
  end
112
+
113
+ def page_recovery_target?(target)
114
+ target.is_a?(Class) && defined?(Weft::Page) && target <= Weft::Page
115
+ end
86
116
  end
87
117
  end
88
118
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # The `self` a verb block runs against — a "void context": empty of
6
+ # anything component-specific, so a block cannot reach local state and is
7
+ # portable to any process. Verb blocks (`derives`, `performs`, `transfers`,
8
+ # `recovers`, `includes`) are `(params) -> value` pure functions: their
9
+ # arguments and return value are explicit, constants resolve lexically, and
10
+ # Kernel stays reachable (raise, format, Integer()).
11
+ #
12
+ # Each execution runs in a fresh instance, dropped once the return value is
13
+ # captured — so a block may use scratch instance variables freely, but that
14
+ # scratch never leaks past its own execution. Instances are deliberately
15
+ # unfrozen (scratch is allowed) yet non-leaking (freshness, not freezing,
16
+ # is what isolates them); a future +register_helpers+ facility would add
17
+ # vocabulary here without changing that contract.
18
+ class Sandbox
19
+ # Run a block in a fresh sandbox and return its value. Extra arguments
20
+ # after +params+ ride through to the block (recovery blocks take
21
+ # +(params, error)+). Each call gets its own instance by construction, so
22
+ # no caller can reuse one across executions.
23
+ def self.run(...) = new.instance_exec(...)
24
+ end
25
+ end
26
+ end
@@ -12,23 +12,43 @@ module Weft
12
12
  end
13
13
 
14
14
  module ClassMethods
15
- # Declare an event to trigger on all action responses for this component.
15
+ # Declare an event this component announces on its action responses.
16
16
  # Sets the HX-Trigger response header.
17
17
  #
18
- # triggers "order-updated"
19
- def triggers(event_name)
20
- own_triggers << event_name.to_s
18
+ # triggers "order-updated" # every action
19
+ # triggers "order-updated", on: :advance # that action only
20
+ # triggers "moved", on: %i[advance retreat]
21
+ #
22
+ # `on:` maps the event to the actions it belongs to. Without it an
23
+ # event is welded to every action the component has, which is rarely
24
+ # what a component with more than one action means.
25
+ #
26
+ # There is deliberately no `when:` counterpart: HX-Trigger announces
27
+ # what a callable did, so a render-context filter has nothing to say
28
+ # about it.
29
+ def triggers(event_name, on: nil)
30
+ own_triggers << { event: event_name.to_s, on: on.nil? ? nil : Array(on) }
21
31
  end
22
32
 
23
- # All declared trigger events (own + inherited).
24
- def trigger_events
25
- if superclass.respond_to?(:trigger_events)
26
- superclass.trigger_events | own_triggers
33
+ # All declared trigger entries (own + inherited), ancestry first.
34
+ def trigger_declarations
35
+ if superclass.respond_to?(:trigger_declarations)
36
+ superclass.trigger_declarations + own_triggers
27
37
  else
28
38
  own_triggers.dup
29
39
  end
30
40
  end
31
41
 
42
+ # The event names that fire for one action. Unfiltered declarations
43
+ # fire on every action, including the nameless one; a subclass can
44
+ # therefore widen an inherited filter by redeclaring the event
45
+ # unfiltered, and duplicates collapse.
46
+ def trigger_events(action_name = nil)
47
+ trigger_declarations.
48
+ select { |trigger| trigger[:on].nil? || trigger[:on].include?(action_name) }.
49
+ map { |trigger| trigger[:event] }.uniq
50
+ end
51
+
32
52
  private
33
53
 
34
54
  def own_triggers
@@ -30,19 +30,30 @@ module Weft
30
30
 
31
31
  # Declare that this component pushes updates via SSE.
32
32
  #
33
- # pushes every: 5.seconds # server pushes on interval
33
+ # pushes every: 5.seconds # server pushes on interval
34
+ # pushes every: 5.seconds, attempts: 5 # custom failure budget
35
+ # pushes every: 5.seconds, immediate: false # wait a full interval first
34
36
  #
35
- # Generates hx-ext="sse", sse-connect, sse-swap on the wrapper element.
36
- # The Router auto-generates a streaming endpoint at
37
+ # Generates hx-ext="sse", sse-connect, sse-swap, sse-close on the
38
+ # wrapper element. The Router auto-generates a streaming endpoint at
37
39
  # /component_path/_stream (the suffix is the stream_suffix config knob).
40
+ # `attempts:` overrides Weft.configuration.push_attempts — consecutive
41
+ # failed pushes tolerated before the Router closes the stream.
42
+ # `immediate:` defaults to true (new subscribers get a state snapshot
43
+ # right away); `immediate: false` opts back into polling-cadence
44
+ # semantics — first frame after one interval — for components whose
45
+ # current state would mislead a fresh subscriber.
38
46
  #
39
47
  # Future: pushes on: "event-name" for event-driven server push (v1.0).
40
- def pushes(every: nil)
48
+ def pushes(every: nil, attempts: nil, immediate: nil)
41
49
  @push_config = {}
42
- return unless every
43
-
44
- ms = interval_in_ms(every, :pushes)
45
- @push_config[:every] = (ms % 1000).zero? ? ms / 1000 : ms / 1000.0
50
+ if every
51
+ ms = interval_in_ms(every, :pushes)
52
+ @push_config[:every] = (ms % 1000).zero? ? ms / 1000 : ms / 1000.0
53
+ end
54
+ @push_config[:attempts] = validated_attempts(attempts) if attempts
55
+ # nil-guarded, not truthiness: `immediate: false` must persist.
56
+ @push_config[:immediate] = immediate unless immediate.nil?
46
57
  end
47
58
 
48
59
  # Push configuration (own or inherited). Returns nil if no pushes declared.
@@ -69,6 +80,18 @@ module Weft
69
80
  @own_refresh_triggers ||= []
70
81
  end
71
82
 
83
+ # A budget below one failed push makes no sense — the stream would
84
+ # close before its first recovery frame; rather than raise
85
+ # mid-declaration, clamp and say so.
86
+ def validated_attempts(attempts)
87
+ return attempts if attempts.is_a?(Integer) && attempts >= 1
88
+
89
+ Weft.logger.warn(
90
+ "#{name} declares `pushes attempts: #{attempts.inspect}`, not a positive integer; using 1"
91
+ )
92
+ 1
93
+ end
94
+
72
95
  # htmx's smallest expressible interval is 1ms; rather than emit an
73
96
  # "every 0s" that polls flat-out, round up and say so.
74
97
  def interval_in_ms(every, verb)
data/lib/weft/error.rb CHANGED
@@ -48,7 +48,7 @@ module Weft
48
48
  InvalidConfiguration = Class.new(Error)
49
49
 
50
50
  # Raised for semantic mistakes in class-body DSL declarations — e.g. a page
51
- # declares attributes but no `page_path`, or `adds_children_to` receives a
51
+ # declares params but no `page_path`, or `adds_children_to` receives a
52
52
  # Symbol that does not start with `@`.
53
53
  InvalidDefinition = Class.new(Error)
54
54
 
@@ -57,8 +57,19 @@ module Weft
57
57
  # assets bundle named at `register_stylesheet`).
58
58
  InvalidUsage = Class.new(Error)
59
59
 
60
+ # Raised by the bang forms of the render-tree lookup (`closest!` / `enclosing!`)
61
+ # when no node matches the requested criteria — the component expected an
62
+ # ancestor that isn't there (it should usually be `dependent!` and rendered
63
+ # only inside that ancestor).
64
+ AncestorNotFound = Class.new(InvalidUsage)
65
+
60
66
  # Raised by the `adds_children_to :@ivar` macro when build returns without
61
67
  # ever assigning the named ivar and then a child is added — almost always
62
68
  # means the developer declared the macro but forgot the matching assignment.
63
69
  MissingContainerIvar = Class.new(InvalidDefinition)
70
+
71
+ # Raised at component construction when a `receives` key with no declared
72
+ # default ends resolution valueless — the call site didn't hand it over and
73
+ # no other source (wire dual, inherited bag) supplied it.
74
+ NotReceived = Class.new(InvalidUsage)
64
75
  end