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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
data/lib/weft/component.rb
CHANGED
|
@@ -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::
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
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(**
|
|
83
|
+
def render(**wire_params)
|
|
67
84
|
klass = self
|
|
68
|
-
Weft::Context.new({}, nil) do
|
|
69
|
-
insert_tag(klass
|
|
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
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
super
|
|
117
|
-
self.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
|
|
123
|
-
# Pass overrides to change specific
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
135
|
-
def
|
|
136
|
-
self.class.
|
|
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
|
-
#
|
|
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",
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
238
|
+
query = serializable_params.compact
|
|
239
|
+
query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}"
|
|
174
240
|
end
|
|
175
241
|
end
|
|
176
242
|
end
|
data/lib/weft/configuration.rb
CHANGED
|
@@ -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,
|
|
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, :
|
|
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)
|
|
16
|
-
|
|
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
|