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
|
@@ -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
|
|
6
|
-
# via `includes` alongside
|
|
7
|
-
#
|
|
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: `
|
|
10
|
-
# `
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
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
|
-
|
|
131
|
-
component =
|
|
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,
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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/
|
|
12
|
-
require "weft/
|
|
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, **
|
|
61
|
-
Redirect.to(target, **
|
|
81
|
+
def redirect(target, **params)
|
|
82
|
+
Redirect.to(target, **params)
|
|
62
83
|
end
|
|
63
84
|
|
|
64
|
-
# Register a named interaction
|
|
85
|
+
# Register a named interaction preset. Delegates to Weft::Presets.
|
|
65
86
|
#
|
|
66
|
-
# Weft.
|
|
67
|
-
def
|
|
68
|
-
|
|
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
|
|
72
|
-
def
|
|
73
|
-
|
|
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/
|
|
136
|
+
require "weft/presets"
|
|
124
137
|
require "weft/defaults"
|