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,7 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "arbre"
3
4
  require "uri"
5
+
6
+ require "weft/context"
4
7
  require "weft/context/interception"
8
+ require "weft/context/traversal"
9
+ require "weft/dsl/actions"
10
+ require "weft/dsl/containers"
11
+ require "weft/dsl/inclusions"
12
+ require "weft/dsl/params"
13
+ require "weft/dsl/recoveries"
14
+ require "weft/dsl/triggers"
15
+ require "weft/dsl/updates"
16
+ require "weft/error"
17
+ require "weft/registry"
18
+ require "weft/registry/eligibility"
19
+ require "weft/router/streaming"
5
20
 
6
21
  module Weft
7
22
  # Base class for all Weft components. Extends Arbre::Component with:
@@ -11,13 +26,14 @@ module Weft
11
26
  class Component < Arbre::Component
12
27
  extend Weft::Registry::Eligibility
13
28
 
14
- include Weft::DSL::Attributes
29
+ include Weft::DSL::Params
15
30
  include Weft::DSL::Recoveries
16
31
  include Weft::DSL::Triggers
17
32
  include Weft::DSL::Inclusions
18
33
  include Weft::DSL::Updates
19
34
  include Weft::DSL::Actions
20
35
  include Weft::DSL::Containers
36
+ include Weft::Context::Traversal
21
37
 
22
38
  class << self
23
39
  # Class-level path override (string or proc). Inherited by subclasses.
@@ -45,12 +61,12 @@ module Weft
45
61
  # Inferred routability from declared state, ignoring any explicit
46
62
  # override (see Weft::Registry::Eligibility#routable?). A component is
47
63
  # independently addressable when it declares interactive behavior —
48
- # attributes, actions, refresh triggers, or push config. Pure
64
+ # params, actions, refresh triggers, or push config. Pure
49
65
  # presentational components (none of those) register but are never served.
50
66
  # Subclasses fall back to this when they have no override of their own, so
51
67
  # an abstract parent does not disable concrete children.
52
68
  def inferred_routable?
53
- attributes.any? || actions.any? || refresh_triggers.any? || !push_config.nil?
69
+ params.any? || actions.any? || refresh_triggers.any? || !push_config.nil?
54
70
  end
55
71
 
56
72
  def inherited(subclass)
@@ -59,29 +75,42 @@ module Weft
59
75
  end
60
76
 
61
77
  # Render this component as an HTML string, outside any Arbre DSL context.
62
- # Used by the Router for partial responses, and available to users for
63
- # testing, REPL exploration, or any standalone rendering need.
78
+ # The kwargs are pseudo-wire: exactly what a request's query string
79
+ # would carry. Used by the Router for partial responses, and available
80
+ # to users for testing, REPL exploration, or any standalone rendering need.
64
81
  #
65
82
  # StatCard.render(status: "shipped") # => "<div id=\"...\">...</div>"
66
- def render(**attributes)
83
+ def render(**wire_params)
67
84
  klass = self
68
- Weft::Context.new({}, nil) do
69
- insert_tag(klass, **attributes)
85
+ Weft::Context.new({}, nil, wire_params: wire_params) do
86
+ insert_tag(klass)
70
87
  end.to_s
71
88
  end
72
89
 
90
+ # Value classes whose instances may suffix a DOM id. An allowlist:
91
+ # arrays, hashes, and rich objects stringify to selector-hostile junk
92
+ # ("member-roster-[]" breaks querySelector), so only honest scalars ride.
93
+ SCALAR_ID_CLASSES = [String, Symbol, Numeric, TrueClass, FalseClass].freeze
94
+
73
95
  # Compute the would-be DOM ID for an instance of this class given a
74
- # plain attrs hash, without instantiating. The Router uses this to
75
- # populate the `:component_id` auto-injected attribute when a recovery
76
- # target opts in. Single source of truth; the instance method delegates.
77
- def weft_id_for(attrs = {})
96
+ # plain params hash, without instantiating. Single source of truth for
97
+ # the convention; the instance method delegates, and the Router falls
98
+ # back to this when it can't construct an instance to ask.
99
+ # The primary value suffixes only when it's a non-blank scalar — nil,
100
+ # "", and non-scalar values all derive the same bare class id, so a
101
+ # component's identity is stable across the ways "no value" arrives.
102
+ def weft_dom_id_for(params = {})
78
103
  base = name.underscore.tr("/", "-").tr("_", "-")
79
- primary_value = attrs.respond_to?(:values) ? attrs.values.first : nil
80
- primary_value ? "#{base}-#{primary_value}" : base
104
+ primary_value = params.respond_to?(:values) ? params.values.first : nil
105
+ identity_suffix?(primary_value) ? "#{base}-#{primary_value}" : base
81
106
  end
82
107
 
83
108
  private
84
109
 
110
+ def identity_suffix?(value)
111
+ SCALAR_ID_CLASSES.any? { |klass| value.is_a?(klass) } && !value.to_s.empty?
112
+ end
113
+
85
114
  # Gem-default name-based path derivation, plus the well-formedness guard.
86
115
  # Mirrors {Weft::Page.default_page_path}: a routable class whose demodulized
87
116
  # name has no usable stem (e.g. a bare +Component+ or +Foo::Component+)
@@ -110,35 +139,69 @@ module Weft
110
139
  recovers from: Weft::NotFound, with: :not_found_component
111
140
  recovers from: StandardError, with: :error_component
112
141
 
142
+ # Params resolve at construction, not build: the context (which carries
143
+ # the wire source and any staged hand-off) is the constructor's one
144
+ # argument, and resolving here makes `params` available even before
145
+ # `super` in user build bodies — the "compute chrome from params, then
146
+ # super" pattern needs that.
147
+ def initialize(*)
148
+ super
149
+ @params = assembled_params
150
+ end
151
+
113
152
  def build(attributes = {})
114
- schema = self.class.attributes
115
- @attrs = Weft::Attributes.extract_from(attributes, using: schema)
116
- super(attributes.except(*schema.keys))
117
- self.id = weft_id
153
+ apply_received_fallback(attributes) unless arbre_context.respond_to?(:take_received!)
154
+ warn_declared_chrome_collisions(attributes)
155
+ super
156
+ self.id = weft_dom_id
157
+ claim_dom_slot!
118
158
  apply_refresh_attrs
119
159
  apply_push_attrs
120
160
  end
121
161
 
122
- # URL to this component's Weft route with current attrs as query params.
123
- # Pass overrides to change specific attr values in the URL.
162
+ # URL to this component's Weft route with current params as query string.
163
+ # Pass overrides to change specific param values in the URL.
124
164
  #
125
165
  # weft_url # => "/_components/orders_panel?status=shipped&page=1"
126
166
  # weft_url(page: 2) # => "/_components/orders_panel?status=shipped&page=2"
127
167
  # weft_url(status: nil, page: 1) # => "/_components/orders_panel?page=1"
128
168
  def weft_url(**overrides)
129
169
  path = self.class.resolved_component_path
130
- params = @attrs.to_h.merge(overrides).compact
131
- params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
170
+ query = serializable_params.merge(overrides).compact
171
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
132
172
  end
133
173
 
134
- # Convention-based DOM ID: dasherized class name + primary attribute value.
135
- def weft_id
136
- self.class.weft_id_for(@attrs ? @attrs.to_h : {})
174
+ # Convention-based DOM ID: dasherized class name + primary wire-param value.
175
+ def weft_dom_id
176
+ self.class.weft_dom_id_for(serializable_params)
137
177
  end
138
178
 
139
179
  private
140
180
 
141
- # URL to this component's Weft route with current attrs (no overrides).
181
+ # Speak for this fragment's DOM slot, or abandon the render.
182
+ #
183
+ # A response delivers at most one fragment per DOM id — that is simply how
184
+ # an out-of-band swap is addressed — so when a slot is already spoken for,
185
+ # continuing would be work thrown away. `super` has run by here but the
186
+ # component's own build body has not, and that body is where the cost is:
187
+ # the derivations it forces, the children it renders. Leaving now costs
188
+ # the caller nothing, because Arbre attaches a tag to its parent only
189
+ # after the build returns.
190
+ #
191
+ # Only roots arbitrate. Duplicate ids among a fragment's own descendants
192
+ # are that fragment's business, not the response's, and a response that
193
+ # has nothing to arbitrate carries no register at all.
194
+ def claim_dom_slot!
195
+ slots = arbre_context.respond_to?(:slots) && arbre_context.slots
196
+ return unless slots && parent.equal?(arbre_context)
197
+ return if slots.add?(id)
198
+
199
+ # Caught by Weft::Router::OOBIncludes#attempt_companion, which turns this
200
+ # into a warning naming both declarations.
201
+ throw Weft::Context::SLOT_TAKEN, id
202
+ end
203
+
204
+ # URL to this component's Weft route with current params (no overrides).
142
205
  # Used internally by apply_refresh_attrs.
143
206
  def refresh_url
144
207
  weft_url
@@ -155,22 +218,25 @@ module Weft
155
218
 
156
219
  # Apply SSE connection attributes for components declaring `pushes`.
157
220
  # Uses innerHTML swap — the wrapper element (holding the SSE connection)
158
- # must persist across pushes.
221
+ # must persist across pushes. sse-close names the protocol event the
222
+ # Router emits when a stream exhausts its push attempts; on receiving it,
223
+ # htmx-ext-sse closes the EventSource instead of auto-reconnecting.
159
224
  def apply_push_attrs
160
225
  config = self.class.push_config
161
226
  return unless config&.key?(:every)
162
227
 
163
228
  set_attribute "hx-ext", "sse"
164
229
  set_attribute "sse-connect", stream_url
165
- set_attribute "sse-swap", weft_id
230
+ set_attribute "sse-swap", weft_dom_id
231
+ set_attribute "sse-close", Weft::Router::Streaming::CLOSE_EVENT
166
232
  set_attribute "hx-swap", "innerHTML"
167
233
  end
168
234
 
169
- # URL to this component's SSE stream endpoint with current attrs.
235
+ # URL to this component's SSE stream endpoint with current wire params.
170
236
  def stream_url
171
237
  path = "#{self.class.resolved_component_path}/#{Weft.configuration.stream_suffix}"
172
- params = @attrs.to_h.compact
173
- params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
238
+ query = serializable_params.compact
239
+ query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
174
240
  end
175
241
  end
176
242
  end
@@ -2,19 +2,23 @@
2
2
 
3
3
  require "logger"
4
4
 
5
+ require "active_support/core_ext/string/inflections"
6
+
5
7
  module Weft
6
8
  class Configuration
7
9
  DEFAULT_COMPONENT_PATH = ->(klass) { "/_components/#{klass.name.to_s.delete_suffix('Component').underscore}" }
8
10
  VALID_HTMX_ERRORS = %i[fragment redirect].freeze
9
11
  VALID_INCLUDE_SSE_EXT = [:auto, true, false].freeze
12
+ CLASS_KNOBS = %i[error_component error_page not_found_page not_found_component].freeze
10
13
  LOG_LEVELS = {
11
14
  debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN,
12
15
  error: Logger::ERROR, fatal: Logger::FATAL, unknown: Logger::UNKNOWN
13
16
  }.freeze
14
- private_constant :DEFAULT_COMPONENT_PATH, :VALID_HTMX_ERRORS, :VALID_INCLUDE_SSE_EXT, :LOG_LEVELS
17
+ private_constant :DEFAULT_COMPONENT_PATH, :VALID_HTMX_ERRORS, :VALID_INCLUDE_SSE_EXT,
18
+ :CLASS_KNOBS, :LOG_LEVELS
15
19
 
16
- attr_reader :component_path, :htmx_errors, :include_sse_ext, :log_level, :stream_suffix
17
- attr_accessor :include_htmx, :auto_reload, :reload_paths, :verbose_error_pages, :router_logging
20
+ attr_reader :component_path, :htmx_errors, :include_sse_ext, :log_level, :push_attempts, :stream_suffix
21
+ attr_accessor :include_htmx, :verbose_error_pages, :router_logging
18
22
  attr_writer :error_component, :error_page, :not_found_page, :not_found_component
19
23
 
20
24
  # @api private
@@ -24,12 +28,11 @@ module Weft
24
28
  @component_path = DEFAULT_COMPONENT_PATH
25
29
  @include_htmx = true
26
30
  @include_sse_ext = :auto
27
- @auto_reload = false
28
- @reload_paths = []
29
31
  @router_logging = false
30
32
  @verbose_error_pages = true
31
33
  @htmx_errors = :fragment
32
34
  @log_level = :info
35
+ @push_attempts = 3
33
36
  @stream_suffix = "_stream"
34
37
  @static_assets = {}
35
38
  end
@@ -70,6 +73,18 @@ module Weft
70
73
  @log_level = value
71
74
  end
72
75
 
76
+ # Consecutive failed pushes tolerated on an SSE stream before the Router
77
+ # closes it (the last failure's recovery frame renders with
78
+ # attempts_remaining 0, then the close event fires). Gem-wide default;
79
+ # override per component via `pushes every:, attempts:`.
80
+ def push_attempts=(value)
81
+ unless value.is_a?(Integer) && value >= 1
82
+ raise ArgumentError, "push_attempts must be an integer >= 1, got #{value.inspect}"
83
+ end
84
+
85
+ @push_attempts = value
86
+ end
87
+
73
88
  # The path segment that marks a component's SSE stream endpoint. The leading
74
89
  # slash is supplied by Weft, so set the bare segment (e.g. "stream", "sse").
75
90
  # It's appended as "<component_path>/<stream_suffix>" on both sides: the
@@ -134,6 +149,23 @@ module Weft
134
149
  @not_found_component ||= Weft::Defaults::NotFoundComponent
135
150
  end
136
151
 
152
+ # Re-resolve the class-valued knobs after a code reload. A knob assigned at
153
+ # boot holds that moment's class object; a reloader that redefines the
154
+ # constant would otherwise leave the knob serving the stale definition until
155
+ # restart. The configure_autoloading reload hook calls this after each
156
+ # reload; hand-rolled reloaders should call it from their own hook. A knob
157
+ # whose constant no longer resolves (mid-unload, or the class was deleted)
158
+ # keeps its last-known class.
159
+ def refresh_stale_classes!
160
+ CLASS_KNOBS.each do |knob|
161
+ klass = instance_variable_get(:"@#{knob}")
162
+ next unless klass.is_a?(Class) && klass.name
163
+
164
+ current = klass.name.safe_constantize
165
+ instance_variable_set(:"@#{knob}", current) if current.is_a?(Class) && !current.equal?(klass)
166
+ end
167
+ end
168
+
137
169
  private
138
170
 
139
171
  def register_static_assets_bundle(name:, root:, from:)
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+ require "uri"
5
+
6
+ require "weft/action"
7
+ require "weft/context/modifiers"
8
+ require "weft/error"
9
+
10
+ module Weft
11
+ class Context < Arbre::Context
12
+ # The element-kwarg expansion engine: turns Weft kwargs on any element
13
+ # into htmx attributes. Mixed into Weft::Context; Interception#insert_tag
14
+ # invokes it on the root context (via +arbre_context+).
15
+ #
16
+ # This module owns the INTERACTION rank of the kwarg grammar — the kwargs
17
+ # that generate request wiring, first match wins: `action:` (Symbol),
18
+ # `navigate:` (Hash), `loads:` (Class), or a registered preset name
19
+ # (Class or URL String). Value shape is the claim — a String action:
20
+ # stays honest form HTML — and a claimed kwarg that cannot resolve
21
+ # raises Weft::InvalidUsage rather than leaking into the HTML. The
22
+ # MODIFIER rank (trigger:/push_url:/confirm:/swap:/target:) lives in
23
+ # Context::Modifiers; the hx-* builders and tree lookups the expanders
24
+ # assemble from live in Context::Wiring.
25
+ module Expansion
26
+ # @api private
27
+ def expand_weft_attrs(attrs, for_class: nil)
28
+ attrs = attrs.dup
29
+ mods = Modifiers::MODIFIER_KEYS.to_h { |k| [k, attrs.delete(k)] }
30
+ validate_with_ownership!(attrs)
31
+ expanded = expand_action(attrs, for_class: for_class) || expand_navigate(attrs) ||
32
+ expand_loads(attrs, mods) || expand_preset(attrs, mods)
33
+ expanded ? overlay_modifiers(expanded, mods) : passthrough_modifiers(attrs, mods)
34
+ end
35
+
36
+ # @api private
37
+ # Whether an attrs hash carries any Weft kwarg.
38
+ def weft_kwarg?(hash) = interaction_kwarg?(hash) || modifier_kwarg?(hash) || claimed?(hash, :with)
39
+
40
+ private
41
+
42
+ def interaction_kwarg?(hash)
43
+ hash[:action].is_a?(Symbol) || claimed?(hash, :navigate) ||
44
+ claimed?(hash, :loads) || find_preset_kwarg(hash)
45
+ end
46
+
47
+ def modifier_kwarg?(hash) = Modifiers::CLAIMING_MODIFIER_KEYS.any? { |k| hash.key?(k) }
48
+
49
+ # A nil value means absent — the conditional-call-site idiom
50
+ # (`tooltip: maybe_class`), mirroring Arbre's nil-attribute omission.
51
+ def claimed?(hash, key) = hash.key?(key) && !hash[key].nil?
52
+
53
+ # with: belongs to loads:/preset expansion; anywhere else it would
54
+ # silently ride into the HTML as junk chrome.
55
+ def validate_with_ownership!(attrs)
56
+ return unless claimed?(attrs, :with)
57
+ return if claimed?(attrs, :loads) || find_preset_kwarg(attrs)
58
+
59
+ raise Weft::InvalidUsage,
60
+ "with: supplies wire params to a loads:/preset target — it needs one of those kwargs alongside it"
61
+ end
62
+
63
+ def expand_action(attrs, for_class: nil)
64
+ action_name = attrs[:action]
65
+ return unless action_name.is_a?(Symbol)
66
+
67
+ component = find_action_context(action_name)
68
+ unless component
69
+ raise Weft::InvalidUsage, "action: #{action_name.inspect} matches no action declared by an " \
70
+ "enclosing component — check the name against its performs/transfers declarations"
71
+ end
72
+
73
+ action = component.class.action_for(action_name)
74
+ htmx = action.to_htmx_attrs(component)
75
+ expanded = attrs.except(:action).merge(htmx)
76
+ return augment_for_form(expanded, action, htmx) if for_class && for_class <= Arbre::HTML::Form
77
+
78
+ expanded
79
+ end
80
+
81
+ def expand_navigate(attrs)
82
+ return unless claimed?(attrs, :navigate)
83
+
84
+ overrides = attrs[:navigate]
85
+ unless overrides.is_a?(Hash)
86
+ raise Weft::InvalidUsage, "navigate: expects a Hash of wire-param overrides, got #{overrides.inspect}"
87
+ end
88
+
89
+ component = navigation_component!
90
+ validate_navigate_keys!(component, overrides)
91
+ attrs.except(:navigate).merge(navigate_attrs(component, overrides))
92
+ end
93
+
94
+ # The enclosing component navigate: re-fetches — present and routable,
95
+ # or the wiring could only 404.
96
+ def navigation_component!
97
+ component = find_nearest_component
98
+ raise Weft::InvalidUsage, "navigate: has no enclosing component whose route it could re-fetch" if component.nil?
99
+
100
+ unless component.class.routable?
101
+ raise Weft::InvalidUsage,
102
+ "navigate: re-fetches #{component.class.name}, which is not routable — its URL can only 404. " \
103
+ "Drop the class's abstract!/dependent! marking, or reach a routable ancestor with enclosing + loads:"
104
+ end
105
+
106
+ component
107
+ end
108
+
109
+ # navigate: re-fetches the component's own route, and the resulting
110
+ # render is standalone — only the component's own declared wire params
111
+ # survive that request. Anything else would silently vanish.
112
+ def validate_navigate_keys!(component, overrides)
113
+ bad = overrides.keys.reject { |k| component.class.params.key?(k) }
114
+ return if bad.empty?
115
+
116
+ raise Weft::InvalidUsage,
117
+ "navigate: #{bad.map(&:inspect).join(', ')} not declared as wire params of #{component.class} — " \
118
+ "only its own declared params survive the standalone re-fetch " \
119
+ "(declare `param #{bad.first.inspect}` on it, or reach an ancestor with enclosing + loads:)"
120
+ end
121
+
122
+ def expand_loads(attrs, mods)
123
+ return unless claimed?(attrs, :loads)
124
+
125
+ target_class = attrs[:loads]
126
+ unless target_class.is_a?(Class)
127
+ raise Weft::InvalidUsage, "loads: expects a component Class, got #{target_class.inspect}"
128
+ end
129
+
130
+ ensure_routable_target!(target_class, :loads)
131
+ validate_loads_kwargs!(mods)
132
+ remaining = attrs.except(:loads, :with)
133
+ remaining.merge(loads_attrs(target_class, resolve_with(attrs), mods[:swap], mods[:target]))
134
+ end
135
+
136
+ def expand_preset(attrs, mods)
137
+ preset_key, target_class = find_preset_kwarg(attrs)
138
+ return unless preset_key
139
+
140
+ build_preset_attrs(attrs, mods, preset_key, target_class, Weft.preset(preset_key))
141
+ end
142
+
143
+ # A preset value is either a target Class (derive the URL from it) or a
144
+ # ready-made URL String (retry-style — the caller already has the URL).
145
+ def find_preset_kwarg(attrs)
146
+ attrs.find do |k, v|
147
+ next false if v.nil? || !Weft.preset(k)
148
+ raise Weft::InvalidUsage, "#{k}: expects a component Class or a URL String, got #{v.inspect}" unless
149
+ v.is_a?(Class) || v.is_a?(String)
150
+
151
+ true
152
+ end
153
+ end
154
+
155
+ def build_preset_attrs(attrs, mods, preset_key, target_or_url, preset)
156
+ ensure_routable_target!(target_or_url, preset_key) if target_or_url.is_a?(Class)
157
+ target = mods[:target] || preset[:target]
158
+ raise Weft::InvalidUsage, "#{preset_key}: requires target: (e.g., target: :self)" unless target
159
+
160
+ htmx = preset_wiring(attrs, target_or_url, preset, mods[:swap] || preset[:swap], target)
161
+ attrs.except(preset_key, :with).merge(htmx)
162
+ end
163
+
164
+ def resolve_with(attrs) = attrs[:with] || find_nearest_component&.serializable_params || {}
165
+
166
+ def validate_loads_kwargs!(mods)
167
+ raise Weft::InvalidUsage, "loads: requires swap: (e.g., swap: :fill)" unless mods[:swap]
168
+ raise Weft::InvalidUsage, "loads: requires target: (e.g., target: :self)" unless mods[:target]
169
+ end
170
+
171
+ # A load target is fetched over the wire at click time — a non-routable
172
+ # class wires a URL that can only 404, silently. Raise at render instead.
173
+ # String URLs (retry-style presets) are unverifiable and pass through;
174
+ # transfers to: is exempt by design (a render target, not a route).
175
+ def ensure_routable_target!(target_class, kwarg)
176
+ return if target_class.routable?
177
+
178
+ raise Weft::InvalidUsage,
179
+ "#{kwarg}: targets #{target_class.name}, which is not routable — the generated URL can only 404. " \
180
+ "Mark it routable! (or drop its abstract!/dependent! marking) so it can serve standalone fetches"
181
+ end
182
+ end
183
+ end
184
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "arbre"
4
+
3
5
  module Weft
4
6
  class Context < Arbre::Context
5
7
  # Mixin for Arbre elements that need to forward Weft kwargs
@@ -12,11 +14,29 @@ module Weft
12
14
  module Interception
13
15
  def insert_tag(klass, *args, &)
14
16
  h = args.last
15
- if h.is_a?(Hash) && arbre_context.is_a?(Weft::Context) && arbre_context.weft_kwarg?(h)
16
- args[-1] = arbre_context.expand_weft_attrs(h, for_class: klass)
17
+ if h.is_a?(Hash) && arbre_context.is_a?(Weft::Context)
18
+ h = stage_received_kwargs(klass, h)
19
+ h = arbre_context.expand_weft_attrs(h, for_class: klass) if arbre_context.weft_kwarg?(h)
20
+ args[-1] = h
17
21
  end
18
22
  super
19
23
  end
24
+
25
+ private
26
+
27
+ # Kwargs naming a target's declared `receives` keys are hand-offs, not
28
+ # chrome: pull them out before Arbre sees them and stage them on the
29
+ # context register for the instance about to be constructed.
30
+ def stage_received_kwargs(klass, attrs)
31
+ return attrs unless klass.respond_to?(:received_params)
32
+
33
+ keys = klass.received_params.keys & attrs.keys
34
+ return attrs if keys.empty?
35
+
36
+ attrs = attrs.dup
37
+ arbre_context.stage_received(klass, keys.to_h { |k| [k, attrs.delete(k)] })
38
+ attrs
39
+ end
20
40
  end
21
41
  end
22
42
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+
5
+ require "weft/action"
6
+
7
+ module Weft
8
+ class Context < Arbre::Context
9
+ # The modifier rank of the element-kwarg grammar: kwargs that adjust
10
+ # whatever htmx wiring the interaction rank generated, applied as
11
+ # call-site overrides after expansion. swap:/target: revert to plain
12
+ # chrome when no interaction kwarg claims the element (target: is real
13
+ # HTML — <a target="_blank">); trigger:/push_url:/confirm: map
14
+ # standalone (all inherit down the DOM in htmx).
15
+ module Modifiers
16
+ MODIFIER_KEYS = %i[trigger push_url confirm swap target].freeze
17
+
18
+ # Modifier kwargs that pull an element into expansion on their own.
19
+ # :target is deliberately absent — bare target: stays honest HTML
20
+ # chrome, untouched.
21
+ CLAIMING_MODIFIER_KEYS = (MODIFIER_KEYS - [:target]).freeze
22
+
23
+ # Warn-once registry for standalone swap: kwargs, keyed by the nearest
24
+ # component class (nil outside any component).
25
+ def self.warned_standalone_swaps
26
+ @warned_standalone_swaps ||= Set.new
27
+ end
28
+
29
+ private
30
+
31
+ # Call-site modifiers win over whatever the interaction expander
32
+ # generated (an action's declared target, a preset's default swap, ...).
33
+ def overlay_modifiers(expanded, mods)
34
+ expanded["hx-swap"] = Action.resolve_swap(mods[:swap]) if mods[:swap]
35
+ expanded["hx-target"] = resolve_target(mods[:target]) if mods[:target]
36
+ apply_universal_modifiers(expanded, mods)
37
+ end
38
+
39
+ # No interaction kwarg claimed the element: swap:/target: revert to
40
+ # chrome — swap with a warning, since swapping nothing is suspect but
41
+ # not wrong — while the universal modifiers still map.
42
+ def passthrough_modifiers(attrs, mods)
43
+ warn_standalone_swap(mods[:swap]) if mods[:swap]
44
+ attrs[:swap] = mods[:swap] if mods[:swap]
45
+ attrs[:target] = mods[:target] if mods[:target]
46
+ apply_universal_modifiers(attrs, mods)
47
+ end
48
+
49
+ def apply_universal_modifiers(attrs, mods)
50
+ attrs["hx-trigger"] = resolve_trigger(mods[:trigger]) if mods[:trigger]
51
+ attrs["hx-push-url"] = mods[:push_url].to_s if mods[:push_url]
52
+ attrs["hx-confirm"] = mods[:confirm] if mods[:confirm]
53
+ attrs
54
+ end
55
+
56
+ def warn_standalone_swap(value)
57
+ klass = find_nearest_component&.class
58
+ return unless Modifiers.warned_standalone_swaps.add?(klass)
59
+
60
+ Weft.logger.warn "swap: #{value.inspect} has no effect without an interaction kwarg " \
61
+ "(action:, navigate:, loads:, or a preset)#{" in #{klass}" if klass} — " \
62
+ "passing it through as an HTML attribute"
63
+ end
64
+
65
+ def resolve_target(target)
66
+ case target
67
+ when :self then "this"
68
+ when String then target
69
+ else
70
+ # Arbre element reference — extract #id
71
+ target.respond_to?(:id) ? "##{target.id}" : target.to_s
72
+ end
73
+ end
74
+
75
+ def resolve_trigger(value) = Action.resolve_trigger(value)
76
+ end
77
+ end
78
+ end