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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +150 -0
  5. data/docs/app-patterns.md +268 -0
  6. data/docs/arbre.md +298 -0
  7. data/docs/configuration.md +219 -0
  8. data/docs/dsl.md +329 -0
  9. data/docs/error-handling.md +136 -0
  10. data/docs/examples/README.md +70 -0
  11. data/docs/examples/active-search.md +125 -0
  12. data/docs/examples/browser-dialogs.md +71 -0
  13. data/docs/examples/bulk-update.md +122 -0
  14. data/docs/examples/click-to-edit.md +113 -0
  15. data/docs/examples/click-to-load.md +77 -0
  16. data/docs/examples/delete-row.md +101 -0
  17. data/docs/examples/edit-row.md +146 -0
  18. data/docs/examples/file-upload.md +96 -0
  19. data/docs/examples/infinite-scroll.md +98 -0
  20. data/docs/examples/inline-expansion.md +98 -0
  21. data/docs/examples/inline-validation.md +101 -0
  22. data/docs/examples/keyboard-shortcuts.md +71 -0
  23. data/docs/examples/lazy-loading.md +96 -0
  24. data/docs/examples/live-ticker.md +91 -0
  25. data/docs/examples/modal-dialog.md +88 -0
  26. data/docs/examples/progress-bar.md +138 -0
  27. data/docs/examples/reset-user-input.md +101 -0
  28. data/docs/examples/tabs.md +102 -0
  29. data/docs/examples/tooltip.md +88 -0
  30. data/docs/examples/updating-other-content.md +169 -0
  31. data/docs/examples/value-select.md +109 -0
  32. data/docs/routing.md +131 -0
  33. data/docs/tutorial.md +372 -0
  34. data/lib/weft/action.rb +83 -0
  35. data/lib/weft/attributes.rb +65 -0
  36. data/lib/weft/component.rb +176 -0
  37. data/lib/weft/configuration.rb +177 -0
  38. data/lib/weft/context/interception.rb +22 -0
  39. data/lib/weft/context.rb +184 -0
  40. data/lib/weft/defaults/error_component.rb +63 -0
  41. data/lib/weft/defaults/error_page.rb +27 -0
  42. data/lib/weft/defaults/not_found_component.rb +44 -0
  43. data/lib/weft/defaults/not_found_page.rb +25 -0
  44. data/lib/weft/defaults.rb +9 -0
  45. data/lib/weft/dsl/actions.rb +72 -0
  46. data/lib/weft/dsl/attributes.rb +43 -0
  47. data/lib/weft/dsl/containers.rb +77 -0
  48. data/lib/weft/dsl/inclusions.rb +49 -0
  49. data/lib/weft/dsl/recoveries.rb +89 -0
  50. data/lib/weft/dsl/triggers.rb +40 -0
  51. data/lib/weft/dsl/updates.rb +86 -0
  52. data/lib/weft/error.rb +64 -0
  53. data/lib/weft/page.rb +371 -0
  54. data/lib/weft/redirect.rb +45 -0
  55. data/lib/weft/registry/eligibility.rb +58 -0
  56. data/lib/weft/registry.rb +202 -0
  57. data/lib/weft/resolver.rb +33 -0
  58. data/lib/weft/router/actions.rb +77 -0
  59. data/lib/weft/router/errors.rb +246 -0
  60. data/lib/weft/router/oob_includes.rb +34 -0
  61. data/lib/weft/router/streaming.rb +63 -0
  62. data/lib/weft/router.rb +191 -0
  63. data/lib/weft/shorthands.rb +57 -0
  64. data/lib/weft/version.rb +5 -0
  65. data/lib/weft.rb +124 -0
  66. 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
@@ -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