weft 0.1.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/docs/app-patterns.md +268 -0
- data/docs/arbre.md +298 -0
- data/docs/configuration.md +219 -0
- data/docs/dsl.md +329 -0
- data/docs/error-handling.md +136 -0
- data/docs/examples/README.md +70 -0
- data/docs/examples/active-search.md +125 -0
- data/docs/examples/browser-dialogs.md +71 -0
- data/docs/examples/bulk-update.md +122 -0
- data/docs/examples/click-to-edit.md +113 -0
- data/docs/examples/click-to-load.md +77 -0
- data/docs/examples/delete-row.md +101 -0
- data/docs/examples/edit-row.md +146 -0
- data/docs/examples/file-upload.md +96 -0
- data/docs/examples/infinite-scroll.md +98 -0
- data/docs/examples/inline-expansion.md +98 -0
- data/docs/examples/inline-validation.md +101 -0
- data/docs/examples/keyboard-shortcuts.md +71 -0
- data/docs/examples/lazy-loading.md +96 -0
- data/docs/examples/live-ticker.md +91 -0
- data/docs/examples/modal-dialog.md +88 -0
- data/docs/examples/progress-bar.md +138 -0
- data/docs/examples/reset-user-input.md +101 -0
- data/docs/examples/tabs.md +102 -0
- data/docs/examples/tooltip.md +88 -0
- data/docs/examples/updating-other-content.md +169 -0
- data/docs/examples/value-select.md +109 -0
- data/docs/routing.md +131 -0
- data/docs/tutorial.md +372 -0
- data/lib/weft/action.rb +83 -0
- data/lib/weft/attributes.rb +65 -0
- data/lib/weft/component.rb +176 -0
- data/lib/weft/configuration.rb +177 -0
- data/lib/weft/context/interception.rb +22 -0
- data/lib/weft/context.rb +184 -0
- data/lib/weft/defaults/error_component.rb +63 -0
- data/lib/weft/defaults/error_page.rb +27 -0
- data/lib/weft/defaults/not_found_component.rb +44 -0
- data/lib/weft/defaults/not_found_page.rb +25 -0
- data/lib/weft/defaults.rb +9 -0
- data/lib/weft/dsl/actions.rb +72 -0
- data/lib/weft/dsl/attributes.rb +43 -0
- data/lib/weft/dsl/containers.rb +77 -0
- data/lib/weft/dsl/inclusions.rb +49 -0
- data/lib/weft/dsl/recoveries.rb +89 -0
- data/lib/weft/dsl/triggers.rb +40 -0
- data/lib/weft/dsl/updates.rb +86 -0
- data/lib/weft/error.rb +64 -0
- data/lib/weft/page.rb +371 -0
- data/lib/weft/redirect.rb +45 -0
- data/lib/weft/registry/eligibility.rb +58 -0
- data/lib/weft/registry.rb +202 -0
- data/lib/weft/resolver.rb +33 -0
- data/lib/weft/router/actions.rb +77 -0
- data/lib/weft/router/errors.rb +246 -0
- data/lib/weft/router/oob_includes.rb +34 -0
- data/lib/weft/router/streaming.rb +63 -0
- data/lib/weft/router.rb +191 -0
- data/lib/weft/shorthands.rb +57 -0
- data/lib/weft/version.rb +5 -0
- data/lib/weft.rb +124 -0
- metadata +169 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
# Maps request params (string keys/values) to component attribute hashes.
|
|
5
|
+
# Coerces types based on attribute defaults. Future home of the reification
|
|
6
|
+
# step (wire primitives → rich objects).
|
|
7
|
+
class Resolver
|
|
8
|
+
def resolve(component_class, params)
|
|
9
|
+
component_class.attributes.each_with_object({}) do |(name, meta), result|
|
|
10
|
+
raw = params[name.to_s] || params[name]
|
|
11
|
+
result[name] = raw.nil? ? meta[:default] : coerce(raw, meta[:default])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def coerce(value, default)
|
|
18
|
+
case default
|
|
19
|
+
when Integer then value.to_i
|
|
20
|
+
when Float then value.to_f
|
|
21
|
+
when true, false then coerce_boolean(value)
|
|
22
|
+
else value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def coerce_boolean(value) # rubocop:disable Naming/PredicateMethod
|
|
27
|
+
case value
|
|
28
|
+
when true, "true", "1" then true
|
|
29
|
+
else false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
class Router
|
|
5
|
+
# Action-dispatch slice of the Router. Resolves a request path to a
|
|
6
|
+
# `[Action, ComponentClass]` pair, invokes the action callable, and
|
|
7
|
+
# renders the result (either the component the action renders or a
|
|
8
|
+
# Weft::Redirect). Error-handling delegates to Weft::Router::Errors via
|
|
9
|
+
# `render_error`; the small `render_action_error` wrapper sets the
|
|
10
|
+
# destructive-swap header before delegating.
|
|
11
|
+
#
|
|
12
|
+
# Depends on Router internals: `resolver`, `filtered_params`,
|
|
13
|
+
# `handle_redirect`, `apply_trigger_header`, `render_oob_includes`,
|
|
14
|
+
# `render_error`, `headers`.
|
|
15
|
+
module Actions
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Parse path into component + action name, look up the action.
|
|
19
|
+
# Returns [action, component_class] or nil.
|
|
20
|
+
def resolve_action(path, http_method)
|
|
21
|
+
component_class, action_name = find_component_and_action(path)
|
|
22
|
+
return nil unless component_class&.routable?
|
|
23
|
+
|
|
24
|
+
key = [action_name, http_method]
|
|
25
|
+
action = component_class.actions[key]
|
|
26
|
+
action ? [action, component_class] : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Walk the path from longest to shortest prefix to find a registered
|
|
30
|
+
# component. Any remaining path segment is the action name.
|
|
31
|
+
def find_component_and_action(path)
|
|
32
|
+
parts = path.split("/").reject(&:empty?)
|
|
33
|
+
(parts.length - 1).downto(0) do |i|
|
|
34
|
+
component_class = Weft.registry.lookup("/#{parts[0..i].join('/')}")
|
|
35
|
+
next unless component_class
|
|
36
|
+
|
|
37
|
+
action_name = i < parts.length - 1 ? parts[i + 1].to_sym : nil
|
|
38
|
+
return [component_class, action_name]
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle_action(action, component_class)
|
|
44
|
+
resolved_attrs = resolver.resolve(component_class, filtered_params)
|
|
45
|
+
attrs = Weft::Attributes.new(resolved_attrs)
|
|
46
|
+
|
|
47
|
+
returned = action.callable&.call(attrs)
|
|
48
|
+
return handle_redirect(returned) if returned.is_a?(Weft::Redirect)
|
|
49
|
+
|
|
50
|
+
render_action_response(action, component_class, resolved_attrs, returned)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
render_action_error(action, component_class, resolved_attrs || {}, e)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Successive resolution across the component-class boundary. The bag
|
|
56
|
+
# accumulates the declaring component's resolved attrs plus any hash the
|
|
57
|
+
# callable returned; the rendered class then runs its OWN resolution pass
|
|
58
|
+
# over the bag, so only its declared attributes reach the builder splat
|
|
59
|
+
# (closing the cross-class leak). The bag itself keeps every key so
|
|
60
|
+
# downstream OOB includes still see callable-returned attrs.
|
|
61
|
+
def render_action_response(action, component_class, resolved_attrs, returned)
|
|
62
|
+
bag = returned.is_a?(Hash) ? resolved_attrs.merge(returned) : resolved_attrs
|
|
63
|
+
apply_trigger_header(component_class)
|
|
64
|
+
html = action.renders.render(**resolver.resolve(action.renders, bag))
|
|
65
|
+
html + render_oob_includes(component_class, Weft::Attributes.new(bag), action_name: action.name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Error handling for actions. Adds HX-Reswap header when the action's
|
|
69
|
+
# swap strategy is destructive (e.g., :delete) so the error fragment
|
|
70
|
+
# renders visibly instead of the element being silently removed.
|
|
71
|
+
def render_action_error(action, component_class, resolved_attrs, error)
|
|
72
|
+
headers["HX-Reswap"] = "outerHTML" if action.swap == :delete
|
|
73
|
+
render_error(component_class, resolved_attrs, error)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
class Router
|
|
5
|
+
# Error-and-recovery slice of the Router. Walks recovers chains for
|
|
6
|
+
# component-context failures (A1–A5, C1–C3) and page-context failures
|
|
7
|
+
# (B1, B2, C1 page-context, C4), implements the htmx_errors = :redirect
|
|
8
|
+
# knob (D1, D2 no-op, D3 no-op), and houses the safety-net fallbacks
|
|
9
|
+
# for unmatched errors and recovery handlers that themselves raise.
|
|
10
|
+
#
|
|
11
|
+
# Also owns the schema-gated auto-injection of recovery attributes
|
|
12
|
+
# (:exception, :request_path, :status_code, :component_id, :retry_url).
|
|
13
|
+
#
|
|
14
|
+
# Depends on Router internals: `resolver`, `headers`, `status`,
|
|
15
|
+
# `request`, `redirect`, `htmx_request?`.
|
|
16
|
+
module Errors # rubocop:disable Metrics/ModuleLength
|
|
17
|
+
# Each entry: { redirect_safe: true } means the auto-injected attribute is
|
|
18
|
+
# included even when building a redirect URL. The component-context
|
|
19
|
+
# attributes (:exception, :component_id, :retry_url) are not redirect-safe
|
|
20
|
+
# — no element to preserve identity of, Exception objects aren't
|
|
21
|
+
# URL-encodable, and the destination Page rebuilds its own URL.
|
|
22
|
+
AUTO_INJECTED_ATTRS = [
|
|
23
|
+
{ key: :exception, redirect_safe: false },
|
|
24
|
+
{ key: :request_path, redirect_safe: true },
|
|
25
|
+
{ key: :status_code, redirect_safe: true },
|
|
26
|
+
{ key: :component_id, redirect_safe: false },
|
|
27
|
+
{ key: :retry_url, redirect_safe: false }
|
|
28
|
+
].freeze
|
|
29
|
+
private_constant :AUTO_INJECTED_ATTRS
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Walk a Page-context recovers chain (B1, B2, C1 page-context, C4).
|
|
34
|
+
# `originating_page_class` is nil for routing misses (no specific Page);
|
|
35
|
+
# the gem-default chain on Weft::Page handles those.
|
|
36
|
+
def handle_page_chain_failure(error, originating_page_class:, originating_attrs: {})
|
|
37
|
+
root = originating_page_class || Weft::Page
|
|
38
|
+
entry = root.recovery_for(error)
|
|
39
|
+
return page_safety_net(error) unless entry
|
|
40
|
+
|
|
41
|
+
# D1: htmx + :redirect knob + gem-default fallthrough → HX-Redirect
|
|
42
|
+
# (D3 carves out routing misses).
|
|
43
|
+
return htmx_redirect_to_error_page(error) if d1_applies?(entry, error)
|
|
44
|
+
|
|
45
|
+
target = root.resolve_recovery_target(entry)
|
|
46
|
+
merged_attrs = recovery_merged_attrs(entry, originating_attrs, error)
|
|
47
|
+
|
|
48
|
+
if page_target?(target)
|
|
49
|
+
dispatch_page_recovery(target, merged_attrs, error)
|
|
50
|
+
else
|
|
51
|
+
render_recovery_component(target, merged_attrs, error, component_ctx: {})
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recovery_merged_attrs(entry, originating_attrs, error)
|
|
56
|
+
block_result = invoke_recovery_block(entry, originating_attrs, error)
|
|
57
|
+
originating_attrs.merge(block_result)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Render or redirect for a Page recovery target. htmx requests get the
|
|
61
|
+
# Page's body content as a fragment; traditional requests get the full
|
|
62
|
+
# document. Status comes from the exception.
|
|
63
|
+
def dispatch_page_recovery(page_class, merged_attrs, error)
|
|
64
|
+
injected = inject_auto_attrs(page_class, merged_attrs, error, on_redirect: false)
|
|
65
|
+
render_attrs = resolver.resolve(page_class, injected)
|
|
66
|
+
status recovery_status(error)
|
|
67
|
+
htmx_request? ? page_body_html(page_class, render_attrs) : page_class.render(**render_attrs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Extract the rendered HTML inside a Page's <body>. For htmx fragment
|
|
71
|
+
# responses to full-document failures — the surrounding doc shell is
|
|
72
|
+
# already on the client; only the body content should swap.
|
|
73
|
+
def page_body_html(page_class, attrs)
|
|
74
|
+
klass = page_class
|
|
75
|
+
ctx = Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }
|
|
76
|
+
page_instance = ctx.children.first
|
|
77
|
+
body_el = page_instance.children.find { |c| c.respond_to?(:tag_name) && c.tag_name == "body" }
|
|
78
|
+
body_el ? body_el.children.join : page_instance.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Last-resort hardcoded response when no recovers entry matched and
|
|
82
|
+
# the gem-default Page can't be rendered. Avoids unbounded recursion
|
|
83
|
+
# while still producing something sensible.
|
|
84
|
+
def page_safety_net(error)
|
|
85
|
+
status recovery_status(error)
|
|
86
|
+
message = "Internal error (#{error.class}: #{error.message})"
|
|
87
|
+
if htmx_request?
|
|
88
|
+
"<div style='padding:1rem;color:#991b1b'>#{message}</div>"
|
|
89
|
+
else
|
|
90
|
+
"<!DOCTYPE html>\n<html><head><title>Error</title></head>" \
|
|
91
|
+
"<body><div style='padding:1rem;color:#991b1b'>#{message}</div></body></html>"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_error(component_class, resolved_attrs, error)
|
|
96
|
+
entry = component_class.recovery_for(error)
|
|
97
|
+
if entry
|
|
98
|
+
# D1: htmx + :redirect knob + gem-default fallthrough → HX-Redirect.
|
|
99
|
+
return htmx_redirect_to_error_page(error) if d1_applies?(entry, error)
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
return render_recovery(component_class, entry, resolved_attrs, error)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
# The recovery handler itself raised — fall through to the hardcoded
|
|
105
|
+
# safety net rather than recursing. Surface the original error;
|
|
106
|
+
# the recovery error is dropped (it's almost always a coding bug).
|
|
107
|
+
Weft.logger.error("Recovery render failed: #{e.class}: #{e.message}")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
status recovery_status(error)
|
|
112
|
+
render_generic_error(component_class, resolved_attrs, error)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# D1 applies when: the htmx_errors knob is :redirect, the request is htmx,
|
|
116
|
+
# the matched entry came from a gem-default (Symbol with:), and the error
|
|
117
|
+
# is not a routing miss (D3 — Weft::NotFound short-circuits the knob).
|
|
118
|
+
def d1_applies?(entry, error)
|
|
119
|
+
Weft.configuration.htmx_errors == :redirect &&
|
|
120
|
+
htmx_request? &&
|
|
121
|
+
entry[:with].is_a?(Symbol) &&
|
|
122
|
+
!error.is_a?(Weft::NotFound)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def htmx_redirect_to_error_page(error)
|
|
126
|
+
target = Weft.configuration.error_page
|
|
127
|
+
attrs = inject_auto_attrs(target, {}, error, on_redirect: true)
|
|
128
|
+
headers["HX-Redirect"] = target.redirect_url(attrs)
|
|
129
|
+
status 204
|
|
130
|
+
""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Execute a matched recovery entry: invoke block, merge attrs, dispatch
|
|
134
|
+
# to either a Page-redirect (HX-Redirect / 302) or a fragment render
|
|
135
|
+
# depending on the target's type.
|
|
136
|
+
def render_recovery(component_class, entry, resolved_attrs, error)
|
|
137
|
+
block_result = invoke_recovery_block(entry, resolved_attrs, error)
|
|
138
|
+
merged_attrs = resolved_attrs.merge(block_result)
|
|
139
|
+
target = component_class.resolve_recovery_target(entry)
|
|
140
|
+
component_ctx = {
|
|
141
|
+
originating_id: component_class.weft_id_for(resolved_attrs),
|
|
142
|
+
retry_url: compute_retry_url(component_class, resolved_attrs)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if page_target?(target)
|
|
146
|
+
redirect_to_recovery_page(target, merged_attrs, error)
|
|
147
|
+
else
|
|
148
|
+
render_recovery_component(target, merged_attrs, error, component_ctx: component_ctx)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# GET URL to render the failing component fresh: its resolved_component_path
|
|
153
|
+
# plus the resolved attrs as query string. For action endpoints this gives
|
|
154
|
+
# the underlying component's view (not the action URL).
|
|
155
|
+
def compute_retry_url(component_class, resolved_attrs)
|
|
156
|
+
url = component_class.resolved_component_path
|
|
157
|
+
params = resolved_attrs.compact
|
|
158
|
+
params.empty? ? url : "#{url}?#{URI.encode_www_form(params)}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def invoke_recovery_block(entry, resolved_attrs, error)
|
|
162
|
+
return {} unless entry[:block]
|
|
163
|
+
|
|
164
|
+
result = entry[:block].call(Weft::Attributes.new(resolved_attrs), error)
|
|
165
|
+
result.is_a?(Hash) ? result : {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def page_target?(target)
|
|
169
|
+
target.is_a?(Class) && defined?(Weft::Page) && target <= Weft::Page
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def redirect_to_recovery_page(target, merged_attrs, error)
|
|
173
|
+
attrs_for_url = inject_auto_attrs(target, merged_attrs, error, on_redirect: true)
|
|
174
|
+
url = target.redirect_url(attrs_for_url)
|
|
175
|
+
|
|
176
|
+
if request.env["HTTP_HX_REQUEST"]
|
|
177
|
+
headers["HX-Redirect"] = url
|
|
178
|
+
status 204
|
|
179
|
+
""
|
|
180
|
+
else
|
|
181
|
+
redirect url, 302
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def render_recovery_component(target, merged_attrs, error, component_ctx:)
|
|
186
|
+
injected = inject_auto_attrs(target, merged_attrs, error,
|
|
187
|
+
on_redirect: false, component_ctx: component_ctx)
|
|
188
|
+
status recovery_status(error)
|
|
189
|
+
# Resolve schema-exact for the target so the failing component's attrs
|
|
190
|
+
# (which may share no schema with the target) can't leak as HTML
|
|
191
|
+
# attributes. Declared auto-injected attrs survive: their defaults are
|
|
192
|
+
# nil, and Resolver#coerce passes non-nil values through unchanged.
|
|
193
|
+
target.render(**resolver.resolve(target, injected))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Schema-gated auto-injection of recovery attributes. A recovers target
|
|
197
|
+
# opts in to receiving each value by declaring `attribute :<key>` — the
|
|
198
|
+
# Router only injects keys the target's schema knows about (and only if
|
|
199
|
+
# the value is non-nil).
|
|
200
|
+
def inject_auto_attrs(target, attrs, error, on_redirect:, component_ctx: {})
|
|
201
|
+
schema = target.respond_to?(:attributes) ? target.attributes : {}
|
|
202
|
+
values = auto_attr_values(error, component_ctx)
|
|
203
|
+
injected = attrs.dup
|
|
204
|
+
AUTO_INJECTED_ATTRS.each do |meta|
|
|
205
|
+
next if on_redirect && !meta[:redirect_safe]
|
|
206
|
+
next unless schema.key?(meta[:key])
|
|
207
|
+
|
|
208
|
+
value = values[meta[:key]]
|
|
209
|
+
injected[meta[:key]] = value unless value.nil?
|
|
210
|
+
end
|
|
211
|
+
injected
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def auto_attr_values(error, component_ctx)
|
|
215
|
+
{
|
|
216
|
+
exception: error,
|
|
217
|
+
request_path: request.path,
|
|
218
|
+
status_code: recovery_status(error),
|
|
219
|
+
component_id: component_ctx[:originating_id],
|
|
220
|
+
retry_url: component_ctx[:retry_url]
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def recovery_status(error)
|
|
225
|
+
error.is_a?(Weft::HTTPError) ? error.status : 500
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_generic_error(component_class, resolved_attrs, error)
|
|
229
|
+
component_name = component_class.name || "Component"
|
|
230
|
+
retry_url = compute_retry_url(component_class, resolved_attrs)
|
|
231
|
+
|
|
232
|
+
Weft::Context.new({}, nil) do
|
|
233
|
+
error_style = "padding:1rem; border:1px solid #fca5a5; border-radius:6px; " \
|
|
234
|
+
"background:#fef2f2; color:#991b1b; font-size:0.875rem"
|
|
235
|
+
div(class: "weft-error", style: error_style) do
|
|
236
|
+
div(style: "font-weight:600; margin-bottom:0.5rem") { text_node "#{component_name} error" }
|
|
237
|
+
div(style: "margin-bottom:0.5rem; font-family:monospace; font-size:0.8rem") do
|
|
238
|
+
text_node "#{error.class}: #{error.message}"
|
|
239
|
+
end
|
|
240
|
+
button "Retry", class: "btn btn-sm btn-outline-danger", retry: retry_url
|
|
241
|
+
end
|
|
242
|
+
end.to_s
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
class Router
|
|
5
|
+
# OOB-include slice of the Router. Renders sibling components declared
|
|
6
|
+
# via `includes` alongside an action response or SSE push, with the
|
|
7
|
+
# `hx-swap-oob` attribute set so htmx swaps each into its own DOM slot.
|
|
8
|
+
#
|
|
9
|
+
# Depends on Router internals: `resolver`, `filtered_params`,
|
|
10
|
+
# `build_component_with_attrs`.
|
|
11
|
+
module OOBIncludes
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Render OOB-swapped components declared via `includes`.
|
|
15
|
+
# Filters by action_name when inclusions declare `on:`.
|
|
16
|
+
def render_oob_includes(component_class, primary_attrs, action_name: nil)
|
|
17
|
+
applicable = component_class.inclusions.select do |inc|
|
|
18
|
+
inc[:on].nil? || inc[:on] == action_name
|
|
19
|
+
end
|
|
20
|
+
return "" if applicable.empty?
|
|
21
|
+
|
|
22
|
+
applicable.map { |inc| render_oob_component(inc, primary_attrs) }.join.html_safe
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render_oob_component(inclusion, primary_attrs)
|
|
26
|
+
wire_attrs = inclusion[:block] ? inclusion[:block].call(primary_attrs) : filtered_params
|
|
27
|
+
resolved = resolver.resolve(inclusion[:component_class], wire_attrs)
|
|
28
|
+
component = build_component_with_attrs(inclusion[:component_class], resolved)
|
|
29
|
+
component.set_attribute("hx-swap-oob", "true")
|
|
30
|
+
component.to_s
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
class Router
|
|
5
|
+
# SSE streaming slice of the Router. Handles `/component_path/<stream_suffix>`
|
|
6
|
+
# requests (stream_suffix defaults to "_stream") for components declaring
|
|
7
|
+
# `pushes every:`, opening a long-lived
|
|
8
|
+
# connection that emits formatted `event:`/`data:` frames on the
|
|
9
|
+
# declared cadence.
|
|
10
|
+
#
|
|
11
|
+
# Depends on Router internals: `build_component`, `render_oob_includes`,
|
|
12
|
+
# `pass`, `content_type`, `headers`, `stream`.
|
|
13
|
+
module Streaming
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def handle_stream_request(path)
|
|
17
|
+
component_path = path.delete_suffix("/#{Weft.configuration.stream_suffix}")
|
|
18
|
+
component_class = Weft.registry.lookup(component_path)
|
|
19
|
+
|
|
20
|
+
if component_class&.push_config&.key?(:every)
|
|
21
|
+
stream_component(component_class)
|
|
22
|
+
else
|
|
23
|
+
pass
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stream_component(component_class)
|
|
28
|
+
content_type "text/event-stream"
|
|
29
|
+
headers "Cache-Control" => "no-cache"
|
|
30
|
+
interval = component_class.push_config[:every]
|
|
31
|
+
klass = component_class
|
|
32
|
+
|
|
33
|
+
stream :keep_open do |out|
|
|
34
|
+
# New subscribers get an immediate state snapshot, then the regular
|
|
35
|
+
# cadence — sleep only kicks in from the second frame onward. The flag
|
|
36
|
+
# flips before the push (not after a *successful* one) so a persistently
|
|
37
|
+
# failing push still throttles on the interval instead of busy-looping.
|
|
38
|
+
after_first = false
|
|
39
|
+
loop do
|
|
40
|
+
sleep interval if after_first
|
|
41
|
+
after_first = true
|
|
42
|
+
push_component_event(out, klass)
|
|
43
|
+
rescue Errno::EPIPE, IOError
|
|
44
|
+
break
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Weft.logger.error("SSE push error for #{klass.name}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def push_component_event(out, component_class)
|
|
52
|
+
component = build_component(component_class)
|
|
53
|
+
html = component.content + render_oob_includes(component_class, component.attrs)
|
|
54
|
+
out << format_sse_event(component.weft_id, html)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def format_sse_event(event_name, html)
|
|
58
|
+
sse_data = html.each_line.map { |line| "data: #{line.chomp}" }.join("\n")
|
|
59
|
+
"event: #{event_name}\n#{sse_data}\n\n"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/weft/router.rb
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
# Rack middleware that auto-generates routes for Weft::Components.
|
|
8
|
+
#
|
|
9
|
+
# GET routes render components as HTML fragments (partial rendering).
|
|
10
|
+
# POST/PUT/DELETE/PATCH routes invoke component actions declared via
|
|
11
|
+
# `performs` or `transfers`, then render the action's target component.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# # Middleware (coexists with any Rack app)
|
|
15
|
+
# use Weft::Router
|
|
16
|
+
#
|
|
17
|
+
# # Standalone
|
|
18
|
+
# run Weft::Router
|
|
19
|
+
class Router < Sinatra::Base
|
|
20
|
+
# Behavior slices live under Weft::Router::*. Required inside the class
|
|
21
|
+
# body so each slice's reopened `class Router` finds the Sinatra::Base
|
|
22
|
+
# superclass already declared.
|
|
23
|
+
require_relative "router/streaming"
|
|
24
|
+
require_relative "router/oob_includes"
|
|
25
|
+
require_relative "router/actions"
|
|
26
|
+
require_relative "router/errors"
|
|
27
|
+
include Streaming
|
|
28
|
+
include OOBIncludes
|
|
29
|
+
include Actions
|
|
30
|
+
include Errors
|
|
31
|
+
|
|
32
|
+
set :logging, false
|
|
33
|
+
set :show_exceptions, false
|
|
34
|
+
# In Sinatra's :test environment, raise_errors defaults to true (so test
|
|
35
|
+
# frameworks see exceptions). Weft's own error block must run instead —
|
|
36
|
+
# uncaught route exceptions feed the Page recovers chain.
|
|
37
|
+
set :raise_errors, false
|
|
38
|
+
set :dump_errors, false
|
|
39
|
+
|
|
40
|
+
# GET: render a component, invoke a nameless GET action, or stream SSE
|
|
41
|
+
get "/*" do
|
|
42
|
+
path = "/#{params['splat'].first}"
|
|
43
|
+
|
|
44
|
+
if stream_request?(path)
|
|
45
|
+
handle_stream_request(path)
|
|
46
|
+
else
|
|
47
|
+
handle_get_request(path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# POST/PUT/DELETE/PATCH: invoke named or nameless actions
|
|
52
|
+
%i[post put delete patch].each do |http_method|
|
|
53
|
+
send(http_method, "/*") do
|
|
54
|
+
path = "/#{params['splat'].first}"
|
|
55
|
+
result = resolve_action(path, http_method)
|
|
56
|
+
|
|
57
|
+
if result
|
|
58
|
+
content_type :html
|
|
59
|
+
handle_action(*result)
|
|
60
|
+
else
|
|
61
|
+
pass
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# In standalone mode (no downstream Rack app), Sinatra's not_found
|
|
67
|
+
# block fires when no route matched. Walk the Weft::Page recovers chain
|
|
68
|
+
# (default → Weft::Defaults::NotFoundPage). In middleware mode, `pass`
|
|
69
|
+
# falls through to the downstream app — this block doesn't fire.
|
|
70
|
+
not_found do
|
|
71
|
+
content_type :html
|
|
72
|
+
handle_page_chain_failure(Weft::NotFound.new(request.path),
|
|
73
|
+
originating_page_class: nil)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Catch any error escaping a route handler and walk the Weft::Page
|
|
77
|
+
# chain. Covers full-document Page render failures that escape
|
|
78
|
+
# render_page's own rescue (and any direct user-raised errors).
|
|
79
|
+
error StandardError do
|
|
80
|
+
e = env["sinatra.error"]
|
|
81
|
+
content_type :html
|
|
82
|
+
handle_page_chain_failure(e, originating_page_class: nil)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# A GET targets a component's SSE stream endpoint when its path ends with
|
|
88
|
+
# "/<stream_suffix>" (default "/_stream"). See Streaming slice.
|
|
89
|
+
def stream_request?(path)
|
|
90
|
+
path.end_with?("/#{Weft.configuration.stream_suffix}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def handle_get_request(path)
|
|
94
|
+
# Action resolution handles both named (/component/action_name)
|
|
95
|
+
# and nameless (/component with actions[[nil, :get]]) GET actions.
|
|
96
|
+
result = resolve_action(path, :get)
|
|
97
|
+
if result
|
|
98
|
+
content_type :html
|
|
99
|
+
return handle_action(*result)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# No action matched — render the component directly if it exists and is routable.
|
|
103
|
+
component_class = Weft.registry.lookup(path)
|
|
104
|
+
if component_class&.routable?
|
|
105
|
+
content_type :html
|
|
106
|
+
return render_component(component_class)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Try page routes (pattern match against registered page paths).
|
|
110
|
+
page_match = Weft.registry.match_page(path)
|
|
111
|
+
if page_match
|
|
112
|
+
content_type :html
|
|
113
|
+
return render_page(*page_match)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
pass
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolver
|
|
120
|
+
@resolver ||= Resolver.new
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def filtered_params
|
|
124
|
+
params.except("splat", "captures")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Render a component as HTML. inner: true returns children only
|
|
128
|
+
# (for SSE innerHTML swap where the wrapper element must persist).
|
|
129
|
+
def render_component(component_class, inner: false)
|
|
130
|
+
resolved_attrs = resolver.resolve(component_class, filtered_params)
|
|
131
|
+
component = build_component_with_attrs(component_class, resolved_attrs)
|
|
132
|
+
inner ? component.content : component.to_s
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
render_error(component_class, resolved_attrs || {}, e)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Build a component instance from the current request params.
|
|
138
|
+
def build_component(component_class)
|
|
139
|
+
resolved_attrs = resolver.resolve(component_class, filtered_params)
|
|
140
|
+
build_component_with_attrs(component_class, resolved_attrs)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build a component instance from pre-resolved attributes.
|
|
144
|
+
def build_component_with_attrs(component_class, resolved_attrs)
|
|
145
|
+
klass = component_class
|
|
146
|
+
attrs = resolved_attrs
|
|
147
|
+
context = Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }
|
|
148
|
+
context.children.first
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Render a Page as a full HTML document. Query/body params and
|
|
152
|
+
# path-extracted params are merged; path params override on key
|
|
153
|
+
# conflicts, matching Sinatra's precedence convention.
|
|
154
|
+
# Page render failures walk the failing Page's recovers chain
|
|
155
|
+
# (B1 / C1 page-context); the gem-default catches StandardError.
|
|
156
|
+
def render_page(page_class, route_params)
|
|
157
|
+
merged_params = filtered_params.merge(route_params)
|
|
158
|
+
resolved_attrs = resolver.resolve(page_class, merged_params)
|
|
159
|
+
klass = page_class
|
|
160
|
+
attrs = resolved_attrs
|
|
161
|
+
Weft::Context.new({}, nil) { insert_tag(klass, **attrs) }.to_s
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
handle_page_chain_failure(e,
|
|
164
|
+
originating_page_class: page_class,
|
|
165
|
+
originating_attrs: resolved_attrs || {})
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def htmx_request?
|
|
169
|
+
request.env["HTTP_HX_REQUEST"] == "true"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Handle a Weft::Redirect return from a callable or recovers block.
|
|
173
|
+
# htmx requests get HX-Redirect header; traditional requests get 302.
|
|
174
|
+
def handle_redirect(redir)
|
|
175
|
+
if request.env["HTTP_HX_REQUEST"]
|
|
176
|
+
headers["HX-Redirect"] = redir.url
|
|
177
|
+
status 204
|
|
178
|
+
""
|
|
179
|
+
else
|
|
180
|
+
redirect redir.url
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def apply_trigger_header(component_class)
|
|
185
|
+
events = component_class.trigger_events
|
|
186
|
+
return if events.empty?
|
|
187
|
+
|
|
188
|
+
headers["HX-Trigger"] = events.join(", ")
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|