weft 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -28
  3. data/README.md +46 -23
  4. data/docs/app-patterns.md +8 -7
  5. data/docs/arbre.md +49 -18
  6. data/docs/configuration.md +42 -35
  7. data/docs/dsl.md +356 -105
  8. data/docs/error-handling.md +64 -24
  9. data/docs/examples/active-search.md +10 -10
  10. data/docs/examples/browser-dialogs.md +10 -10
  11. data/docs/examples/bulk-update.md +11 -11
  12. data/docs/examples/click-to-edit.md +14 -14
  13. data/docs/examples/click-to-load.md +8 -8
  14. data/docs/examples/delete-row.md +17 -19
  15. data/docs/examples/edit-row.md +17 -14
  16. data/docs/examples/file-upload.md +5 -5
  17. data/docs/examples/infinite-scroll.md +8 -8
  18. data/docs/examples/inline-expansion.md +8 -8
  19. data/docs/examples/inline-validation.md +17 -17
  20. data/docs/examples/lazy-loading.md +8 -8
  21. data/docs/examples/live-ticker.md +1 -1
  22. data/docs/examples/modal-dialog.md +3 -3
  23. data/docs/examples/progress-bar.md +1 -1
  24. data/docs/examples/reset-user-input.md +7 -7
  25. data/docs/examples/tabs.md +4 -4
  26. data/docs/examples/tooltip.md +8 -8
  27. data/docs/examples/updating-other-content.md +9 -9
  28. data/docs/examples/value-select.md +11 -11
  29. data/docs/params.md +112 -0
  30. data/docs/routing.md +13 -13
  31. data/docs/tutorial.md +46 -48
  32. data/lib/weft/action.rb +4 -2
  33. data/lib/weft/autoloading.rb +69 -0
  34. data/lib/weft/component.rb +97 -31
  35. data/lib/weft/configuration.rb +37 -5
  36. data/lib/weft/context/expansion.rb +184 -0
  37. data/lib/weft/context/interception.rb +22 -2
  38. data/lib/weft/context/modifiers.rb +78 -0
  39. data/lib/weft/context/traversal.rb +80 -0
  40. data/lib/weft/context/wiring.rb +85 -0
  41. data/lib/weft/context.rb +70 -164
  42. data/lib/weft/defaults/error_component.rb +57 -21
  43. data/lib/weft/defaults/error_page.rb +12 -10
  44. data/lib/weft/defaults/not_found_component.rb +14 -12
  45. data/lib/weft/defaults/not_found_page.rb +9 -8
  46. data/lib/weft/dsl/actions.rb +9 -9
  47. data/lib/weft/dsl/inclusions.rb +48 -11
  48. data/lib/weft/dsl/params.rb +265 -0
  49. data/lib/weft/dsl/recoveries.rb +36 -6
  50. data/lib/weft/dsl/sandbox.rb +26 -0
  51. data/lib/weft/dsl/triggers.rb +28 -8
  52. data/lib/weft/dsl/updates.rb +31 -8
  53. data/lib/weft/error.rb +12 -1
  54. data/lib/weft/page/assets.rb +222 -0
  55. data/lib/weft/page/head.rb +87 -0
  56. data/lib/weft/page.rb +55 -239
  57. data/lib/weft/params/assembly.rb +170 -0
  58. data/lib/weft/params.rb +138 -0
  59. data/lib/weft/presets.rb +96 -0
  60. data/lib/weft/redirect.rb +7 -7
  61. data/lib/weft/registry/eligibility.rb +5 -19
  62. data/lib/weft/registry.rb +58 -18
  63. data/lib/weft/resolver.rb +48 -20
  64. data/lib/weft/router/actions.rb +106 -22
  65. data/lib/weft/router/errors.rb +223 -83
  66. data/lib/weft/router/oob_includes.rb +202 -17
  67. data/lib/weft/router/streaming.rb +86 -20
  68. data/lib/weft/router.rb +33 -25
  69. data/lib/weft/version.rb +1 -1
  70. data/lib/weft.rb +37 -24
  71. metadata +32 -8
  72. data/lib/weft/attributes.rb +0 -65
  73. data/lib/weft/dsl/attributes.rb +0 -43
  74. data/lib/weft/shorthands.rb +0 -57
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+
5
+ module Weft
6
+ class Context < Arbre::Context
7
+ # Render-tree navigation for components and pages: reach an ancestor — or
8
+ # self — in the Arbre tree by type or tag, to read its identity (class,
9
+ # weft_dom_id, route, page path). This is the "child affects ancestor"
10
+ # affordance: a nested component discovers what it needs to target instead
11
+ # of being hand-fed its parent's identity.
12
+ #
13
+ # Mixed into Weft::Component and Weft::Page (not Context itself — the tree's
14
+ # root has no ancestors to walk). Lives here beside Interception as the
15
+ # other render-context mechanism the two share.
16
+ #
17
+ # Call inside `build`. (There is no instance, and no render tree to walk,
18
+ # inside a verb block.) Lean on the ancestor's identity and params — fixed
19
+ # at construction — rather than state its own `build` may not have set yet,
20
+ # since it is mid-build above you.
21
+ module Traversal
22
+ # The nearest node matching +matcher+, self included, walking up the tree —
23
+ # impedance-matched to the DOM's Element.closest(): include-self,
24
+ # nearest-first, nil if none.
25
+ #
26
+ # closest(Weft::Page) # nearest enclosing page
27
+ # closest(OrdersPanel) # nearest panel of that type
28
+ # closest(Paginatable) # nearest node playing that role
29
+ # closest(:div) # nearest <div> (self included)
30
+ # closest(Weft::Component) { |c| c.params.key?(:order_id) } # refined
31
+ #
32
+ # +matcher+ is a Class/Module (matched is_a?, subclass- and
33
+ # include-inclusive) or a Symbol (matched against tag_name). An optional
34
+ # block refines: a candidate must match the positional AND the block.
35
+ # Returns the matching Arbre node — a component for a class match, a plain
36
+ # element for a tag — or nil.
37
+ def closest(matcher, include_self: true, &refine)
38
+ unless matcher.is_a?(Module) || matcher.is_a?(Symbol)
39
+ raise ArgumentError,
40
+ "closest matcher must be a Class, Module, or Symbol tag name (got #{matcher.class})"
41
+ end
42
+
43
+ node = include_self ? self : parent
44
+ while node
45
+ return node if traversal_match?(node, matcher, refine)
46
+
47
+ node = node.parent
48
+ end
49
+ nil
50
+ end
51
+
52
+ # closest strictly above self (self excluded) — the expressive read for
53
+ # "reach my enclosing X". Does not take include_self; use closest for that.
54
+ def enclosing(matcher, &) = closest(matcher, include_self: false, &)
55
+
56
+ # closest that raises Weft::AncestorNotFound instead of returning nil — for
57
+ # a component that requires the ancestor (pair with dependent!).
58
+ def closest!(matcher, include_self: true, &)
59
+ closest(matcher, include_self: include_self, &) ||
60
+ raise(Weft::AncestorNotFound,
61
+ "no #{matcher.inspect} #{include_self ? 'at or above' : 'above'} #{self.class}")
62
+ end
63
+
64
+ # enclosing that raises instead of returning nil.
65
+ def enclosing!(matcher, &) = closest!(matcher, include_self: false, &)
66
+
67
+ private
68
+
69
+ def traversal_match?(node, matcher, refine)
70
+ matched =
71
+ if matcher.is_a?(Symbol)
72
+ node.respond_to?(:tag_name) && node.tag_name.to_s == matcher.to_s
73
+ else
74
+ node.is_a?(matcher)
75
+ end
76
+ matched && (refine.nil? || refine.call(node))
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+ require "uri"
5
+
6
+ require "weft/action"
7
+
8
+ module Weft
9
+ class Context < Arbre::Context
10
+ # The low-level wiring layer under the kwarg grammar: htmx attribute
11
+ # builders and render-tree lookups. Context::Expansion decides WHICH
12
+ # interaction claims an element; these methods build the actual hx-*
13
+ # hashes and find the components they point at. Mixed into Weft::Context
14
+ # alongside Expansion and Modifiers (whose resolve_swap/resolve_target
15
+ # vocabulary the builders lean on).
16
+ module Wiring
17
+ private
18
+
19
+ # On <form> elements, also emit the HTML action and method attributes so
20
+ # non-JS submission works (browser POSTs to the same URL htmx would).
21
+ # Drop hx-vals because the form fields are the submission payload —
22
+ # hx-vals would duplicate or shadow them.
23
+ def augment_for_form(expanded, action, htmx)
24
+ url = htmx["hx-#{action.method}"]
25
+ expanded.except("hx-vals").merge("action" => url, "method" => action.method.to_s)
26
+ end
27
+
28
+ def navigate_attrs(component, overrides)
29
+ {
30
+ "hx-get" => component.weft_url(**overrides),
31
+ "hx-target" => "##{component.weft_dom_id}",
32
+ "hx-swap" => "outerHTML"
33
+ }
34
+ end
35
+
36
+ def preset_wiring(attrs, target_or_url, preset, swap, target)
37
+ htmx = if target_or_url.is_a?(String)
38
+ htmx_get_attrs(target_or_url, swap, target)
39
+ else
40
+ loads_attrs(target_or_url, resolve_with(attrs), swap, target)
41
+ end
42
+ htmx["hx-trigger"] = resolve_trigger(preset[:trigger]) if preset[:trigger]
43
+ htmx
44
+ end
45
+
46
+ def loads_attrs(target_class, with_attrs, swap, target)
47
+ htmx_get_attrs(component_url(target_class, with_attrs), swap, target)
48
+ end
49
+
50
+ def component_url(target_class, with_attrs)
51
+ path = target_class.resolved_component_path
52
+ params = with_attrs.compact
53
+ params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
54
+ end
55
+
56
+ def htmx_get_attrs(url, swap, target)
57
+ {
58
+ "hx-get" => url,
59
+ "hx-swap" => Action.resolve_swap(swap),
60
+ "hx-target" => resolve_target(target)
61
+ }
62
+ end
63
+
64
+ def find_action_context(action_name)
65
+ el = current_arbre_element
66
+ while el
67
+ return el if el.is_a?(Weft::Component) && el.class.action_for(action_name)
68
+
69
+ el = el.parent
70
+ end
71
+ nil
72
+ end
73
+
74
+ def find_nearest_component
75
+ el = current_arbre_element
76
+ while el
77
+ return el if el.is_a?(Weft::Component)
78
+
79
+ el = el.parent
80
+ end
81
+ nil
82
+ end
83
+ end
84
+ end
85
+ end
data/lib/weft/context.rb CHANGED
@@ -1,184 +1,90 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "arbre"
4
+
5
+ require "weft/context/expansion"
3
6
  require "weft/context/interception"
7
+ require "weft/context/modifiers"
8
+ require "weft/context/wiring"
4
9
 
5
10
  module Weft
6
- # Arbre::Context subclass that intercepts element creation to expand
7
- # Weft kwargs into htmx attributes.
11
+ # Arbre::Context subclass that intercepts element creation to expand Weft
12
+ # kwargs into htmx attributes — the kwarg vocabulary and its claim rules
13
+ # live in Context::Expansion.
8
14
  #
9
15
  # Works at every nesting depth because Arbre instance_evals the top-level
10
16
  # block, making the Context the receiver for all insert_tag calls throughout
11
17
  # the element tree.
12
- #
13
- # Supported kwargs:
14
- # - `action: :name` — expands into htmx attrs for a declared performs/transfers action
15
- # - `navigate: { key: val }` — expands into htmx GET to self with overridden attrs
16
- # - `trigger: "event"` — sets hx-trigger (standalone or alongside action:/navigate:)
17
18
  class Context < Arbre::Context
19
+ include Expansion
18
20
  include Interception
19
-
20
- # @api private
21
- # Expands Weft kwargs into htmx attributes. Invoked by the Interception
22
- # mixin's #insert_tag on the root Weft::Context (via +arbre_context+).
23
- def expand_weft_attrs(attrs, for_class: nil)
24
- attrs = attrs.dup
25
- custom_trigger = attrs.delete(:trigger)
26
- push_url = attrs.delete(:push_url)
27
- attrs = expand_action(attrs, for_class: for_class) || expand_navigate(attrs) || expand_loads(attrs) ||
28
- expand_shorthand(attrs) || attrs
29
- attrs["hx-trigger"] = resolve_trigger(custom_trigger) if custom_trigger
30
- attrs["hx-push-url"] = push_url.to_s if push_url
31
- attrs
21
+ include Modifiers
22
+ include Wiring
23
+
24
+ # The render's wire params (query/body/path values), carried on the
25
+ # context so every component in the tree resolves its own declared
26
+ # params at any depth. Assigned before super because Arbre's initialize
27
+ # instance_evals the construction block — the tree builds during super.
28
+ attr_reader :wire_params
29
+
30
+ # Request-scoped overlay values — the accumulated verb-block deltas
31
+ # (action callable returns, includes deltas, recovery injections). In
32
+ # the source stack an overlay entry speaks AS the wire for its key:
33
+ # a value overrides the wire's, a nil clears it (resolution falls
34
+ # below). One universe per request; these are its amendments.
35
+ attr_reader :overlays
36
+
37
+ # A bag for ROOT components to branch from, standing in for the tree
38
+ # ancestor a root doesn't have — how an OOB companion inherits its
39
+ # primary's bag (rich values included) exactly like a child built in
40
+ # the primary's own build.
41
+ attr_reader :branch_bag
42
+
43
+ # The DOM ids this RESPONSE has already spoken for — a Set shared across
44
+ # every context the response builds in, because the primary and each of
45
+ # its companions get their own. An out-of-band swap is addressed by DOM
46
+ # id, so only one fragment per id can land; a root component claims its
47
+ # id as it builds (Component#claim_dom_slot!) and a second claimant
48
+ # abandons its render by throwing SLOT_TAKEN. Absent on renders with
49
+ # nothing to arbitrate, and nothing is claimed then.
50
+ attr_reader :slots
51
+
52
+ # Thrown with the contested DOM id when a root loses a slot. Caught by
53
+ # whoever asked for the render; nothing partial reaches the tree, because
54
+ # Arbre adds a tag to its parent only after the build returns.
55
+ SLOT_TAKEN = :weft_slot_taken
56
+
57
+ # Two positional parameters are Arbre's own signature; the four keywords
58
+ # are Weft's render-scoped channels, each independently optional. That
59
+ # they have grown to four is a fair signal that they want a render-environment
60
+ # object of their own — a change that would touch every render path and
61
+ # a documented constructor, so it belongs with the lifecycle work, not here.
62
+ def initialize(assigns = {}, helpers = nil, wire_params: nil, overlays: nil, # rubocop:disable Metrics/ParameterLists
63
+ branch_bag: nil, slots: nil, &)
64
+ @wire_params = wire_params || {}
65
+ @overlays = overlays || {}
66
+ @branch_bag = branch_bag
67
+ @slots = slots
68
+ super(assigns, helpers, &)
32
69
  end
33
70
 
34
71
  # @api private
35
- # Guard check for whether an attrs hash carries any Weft kwarg. Invoked by
36
- # the Interception mixin's #insert_tag on the root Weft::Context (via
37
- # +arbre_context+).
38
- def weft_kwarg?(hash)
39
- hash[:action].is_a?(Symbol) || hash.key?(:trigger) || hash[:navigate].is_a?(Hash) ||
40
- hash[:loads].is_a?(Class) || hash.key?(:push_url) || find_shorthand_kwarg(hash)
41
- end
42
-
43
- private
44
-
45
- def expand_action(attrs, for_class: nil)
46
- action_name = attrs[:action]
47
- return unless action_name.is_a?(Symbol)
48
-
49
- component = find_action_context(action_name)
50
- return unless component
51
-
52
- action = component.class.action_for(action_name)
53
- htmx = action.to_htmx_attrs(component)
54
- expanded = attrs.except(:action).merge(htmx)
55
- return augment_for_form(expanded, action, htmx) if for_class && for_class <= Arbre::HTML::Form
56
-
57
- expanded
58
- end
59
-
60
- # On <form> elements, also emit the HTML action and method attributes so
61
- # non-JS submission works (browser POSTs to the same URL htmx would).
62
- # Drop hx-vals because the form fields are the submission payload —
63
- # hx-vals would duplicate or shadow them.
64
- def augment_for_form(expanded, action, htmx)
65
- url = htmx["hx-#{action.method}"]
66
- expanded.except("hx-vals").merge("action" => url, "method" => action.method.to_s)
67
- end
68
-
69
- def expand_navigate(attrs)
70
- overrides = attrs[:navigate]
71
- return unless overrides.is_a?(Hash)
72
-
73
- component = find_nearest_component
74
- return unless component
75
-
76
- attrs.except(:navigate).merge(navigate_attrs(component, overrides))
77
- end
78
-
79
- def expand_loads(attrs)
80
- target_class = attrs[:loads]
81
- return unless target_class.is_a?(Class)
82
-
83
- validate_loads_kwargs!(attrs)
84
- remaining = attrs.except(:loads, :swap, :target, :with)
85
- remaining.merge(loads_attrs(target_class, resolve_with(attrs), attrs[:swap], attrs[:target]))
86
- end
87
-
88
- def expand_shorthand(attrs)
89
- shorthand_key, target_class = find_shorthand_kwarg(attrs)
90
- return unless shorthand_key
91
-
92
- build_shorthand_attrs(attrs, shorthand_key, target_class, Weft.shorthand(shorthand_key))
93
- end
94
-
95
- # A shorthand value is either a target Class (derive the URL from it) or a
96
- # ready-made URL String (retry-style — the caller already has the URL).
97
- def find_shorthand_kwarg(attrs)
98
- attrs.find { |k, v| (v.is_a?(Class) || v.is_a?(String)) && Weft.shorthand(k) }
72
+ # One-shot register for `receives` hand-offs. Interception stages the
73
+ # extracted kwargs here immediately before Arbre constructs the target
74
+ # (insert_tag → build_tag → new); the new instance consumes them during
75
+ # params assembly. Class-checked so a stale staging can never leak into
76
+ # a different component's bag.
77
+ def stage_received(klass, values)
78
+ @staged_received = [klass, values]
99
79
  end
100
80
 
101
- def build_shorthand_attrs(attrs, shorthand_key, target_or_url, preset)
102
- target = attrs[:target] || preset[:target]
103
- raise ArgumentError, "#{shorthand_key}: requires target: (e.g., target: :self)" unless target
104
-
105
- swap = attrs[:swap] || preset[:swap]
106
- htmx = if target_or_url.is_a?(String)
107
- htmx_get_attrs(target_or_url, swap, target)
108
- else
109
- loads_attrs(target_or_url, resolve_with(attrs), swap, target)
110
- end
111
- htmx["hx-trigger"] = resolve_trigger(preset[:trigger]) if preset[:trigger]
112
- attrs.except(shorthand_key, :swap, :target, :with).merge(htmx)
113
- end
114
-
115
- def resolve_with(attrs)
116
- attrs[:with] || find_nearest_component&.attrs&.to_h || {}
117
- end
118
-
119
- def validate_loads_kwargs!(attrs)
120
- raise ArgumentError, "loads: requires swap: (e.g., swap: :fill)" unless attrs[:swap]
121
- raise ArgumentError, "loads: requires target: (e.g., target: :self)" unless attrs[:target]
122
- end
123
-
124
- def find_action_context(action_name)
125
- el = current_arbre_element
126
- while el
127
- return el if el.is_a?(Weft::Component) && el.class.action_for(action_name)
128
-
129
- el = el.parent
130
- end
131
- nil
132
- end
133
-
134
- def find_nearest_component
135
- el = current_arbre_element
136
- while el
137
- return el if el.is_a?(Weft::Component)
138
-
139
- el = el.parent
140
- end
141
- nil
142
- end
143
-
144
- def navigate_attrs(component, overrides)
145
- {
146
- "hx-get" => component.weft_url(**overrides),
147
- "hx-target" => "##{component.weft_id}",
148
- "hx-swap" => "outerHTML"
149
- }
150
- end
151
-
152
- def loads_attrs(target_class, with_attrs, swap, target)
153
- htmx_get_attrs(component_url(target_class, with_attrs), swap, target)
154
- end
155
-
156
- def component_url(target_class, with_attrs)
157
- path = target_class.resolved_component_path
158
- params = with_attrs.compact
159
- params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
160
- end
161
-
162
- def htmx_get_attrs(url, swap, target)
163
- {
164
- "hx-get" => url,
165
- "hx-swap" => Action.resolve_swap(swap),
166
- "hx-target" => resolve_target(target)
167
- }
168
- end
169
-
170
- def resolve_target(target)
171
- case target
172
- when :self then "this"
173
- when String then target
174
- else
175
- # Arbre element reference — extract #id
176
- target.respond_to?(:id) ? "##{target.id}" : target.to_s
177
- end
178
- end
81
+ # @api private
82
+ def take_received!(klass)
83
+ staged_class, values = @staged_received
84
+ return unless staged_class.equal?(klass)
179
85
 
180
- def resolve_trigger(value)
181
- Action.resolve_trigger(value)
86
+ @staged_received = nil
87
+ values
182
88
  end
183
89
  end
184
90
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/component"
4
+
3
5
  module Weft
4
6
  module Defaults
5
7
  # Gem-default component rendered when an action or partial-render fails
@@ -7,56 +9,90 @@ module Weft
7
9
  # exception class and message; non-verbose shows a generic message.
8
10
  # Users override by setting Weft.configuration.error_component or by
9
11
  # declaring their own `recovers` chain.
12
+ #
13
+ # Doubles as the default recovery frame for SSE push failures: the Router
14
+ # injects :attempts_remaining only on that path, so its presence switches
15
+ # the build into push shape — a countdown notice while the stream retries,
16
+ # then a stopped notice with a reopen button on the final frame.
10
17
  class ErrorComponent < Weft::Component
11
18
  abstract!
12
19
 
13
- # Auto-injected attributes (opt-in, schema-gated by the Router). The
20
+ # Auto-injected params (opt-in, schema-gated by the Router). The
14
21
  # Router populates these at error-handling time on any recovers target
15
22
  # that declares them.
16
- # :component_id preserves the failing component's DOM identity so the
17
- # recovered fragment lands at the original element's id — preventing
18
- # duplicate IDs when several siblings fail in the same window.
19
- # :retry_url is the failing component's GET URL with current attrs.
20
- attribute :component_id
21
- attribute :exception
22
- attribute :request_path
23
- attribute :status_code
24
- attribute :retry_url
23
+ # :retry_url is the failing component's GET URL with current params.
24
+ # :attempts_remaining is the push-path countdown (nil on HTTP paths).
25
+ # (Identity is not among them the Router stamps the failing
26
+ # component's DOM id onto every recovery fragment itself.)
27
+ param :component_tag, type: :string
28
+ param :exception
29
+ param :request_path, type: :string
30
+ param :status_code, type: :integer
31
+ param :retry_url, type: :string
32
+ param :attempts_remaining, type: :integer
25
33
 
26
34
  STYLE = "padding:1rem; border:1px solid #fca5a5; border-radius:6px; " \
27
35
  "background:#fef2f2; color:#991b1b; font-size:0.875rem"
36
+ HEADING_STYLE = "font-weight:600; margin-bottom:0.5rem"
28
37
  MONO_STYLE = "margin-bottom:0.5rem; font-family:monospace; font-size:0.8rem"
29
38
  BUTTON_STYLE = "padding:0.25rem 0.75rem; border:1px solid #b91c1c; border-radius:4px; " \
30
39
  "background:#fff; color:#b91c1c; font-size:0.75rem; cursor:pointer"
31
40
 
32
41
  def build(attributes = {})
33
42
  super
43
+ return build_push_error if push_context?
44
+
34
45
  add_class "weft-error"
35
46
  set_attribute "style", STYLE
36
47
 
37
- div(style: "font-weight:600; margin-bottom:0.5rem") { text_node "Something went wrong" }
38
- render_verbose if Weft.configuration.verbose_error_pages && @attrs.exception
39
- render_retry_button if @attrs.retry_url
48
+ div(style: HEADING_STYLE) { text_node "Something went wrong" }
49
+ render_verbose if verbose?
50
+ render_retry_button if @params.retry_url
40
51
  end
41
52
 
42
- # Preserve the failing component's DOM identity when the Router injected
43
- # :component_id. Otherwise fall back to the class-derived default.
44
- def weft_id
45
- @attrs.component_id || super
53
+ # Adopt the failing component's wrapper tag when the Router injected
54
+ # :component_tag a div swapped into a <tr>'s position is invalid
55
+ # table content; a <tr> error row is not.
56
+ def tag_name
57
+ @params.component_tag || super
46
58
  end
47
59
 
48
60
  private
49
61
 
62
+ def push_context? = !@params.attempts_remaining.nil?
63
+ def verbose? = Weft.configuration.verbose_error_pages && @params.exception
64
+
65
+ # Push frames ship children only — the persistent wrapper client-side
66
+ # belongs to the original component — so the visual box must be an inner
67
+ # element; anything set on the wrapper would never reach the browser.
68
+ def build_push_error
69
+ stopped = @params.attempts_remaining.zero?
70
+ div(class: "weft-error", style: STYLE) do
71
+ div(style: HEADING_STYLE) do
72
+ text_node stopped ? "Live updates stopped" : "Live updates interrupted — retrying"
73
+ end
74
+ render_verbose if verbose?
75
+ render_reopen_button if stopped && @params.retry_url
76
+ end
77
+ end
78
+
50
79
  def render_verbose
51
- exc = @attrs.exception
80
+ exc = @params.exception
52
81
  div(style: MONO_STYLE) { text_node "#{exc.class}: #{exc.message}" }
53
82
  end
54
83
 
55
84
  # Retry by re-issuing a GET to refresh the failing component. The :retry
56
- # shorthand supplies the htmx wiring (outerHTML-swap the closest
57
- # .weft-error box), so it works whether or not :component_id was carved out.
85
+ # preset supplies the htmx wiring (outerHTML-swap the closest
86
+ # .weft-error box), so it works wherever the fragment happens to land.
58
87
  def render_retry_button
59
- button "Retry", retry: @attrs.retry_url, style: BUTTON_STYLE
88
+ button "Retry", retry: @params.retry_url, style: BUTTON_STYLE
89
+ end
90
+
91
+ # Resume by re-fetching the component whole: the fresh wrapper's
92
+ # sse-connect reopens the stream with a full attempts budget. The
93
+ # :reopen_stream preset targets the dead wrapper itself.
94
+ def render_reopen_button
95
+ button "Resume live updates", reopen_stream: @params.retry_url, style: BUTTON_STYLE
60
96
  end
61
97
  end
62
98
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/page"
4
+
3
5
  module Weft
4
6
  module Defaults
5
7
  # Gem-default full-document page rendered for traditional (non-htmx)
@@ -8,19 +10,19 @@ module Weft
8
10
  class ErrorPage < Weft::Page
9
11
  self.page_path = "/_weft/error"
10
12
 
11
- attribute :exception
12
- attribute :request_path
13
- attribute :status_code
13
+ param :exception
14
+ param :request_path, type: :string
15
+ param :status_code, type: :integer
16
+
17
+ title "Error"
14
18
 
19
+ # The component reads the same request the page did — recovery values
20
+ # ride as overlays and reach every depth — so handing them over as
21
+ # builder kwargs would only render them as HTML attributes on the error
22
+ # box, exception message and all.
15
23
  def build(attributes = {})
16
- attributes[:title] ||= "Error"
17
24
  super
18
- insert_tag(
19
- Weft::Defaults::ErrorComponent,
20
- exception: @attrs.exception,
21
- request_path: @attrs.request_path,
22
- status_code: @attrs.status_code
23
- )
25
+ insert_tag(Weft::Defaults::ErrorComponent)
24
26
  end
25
27
  end
26
28
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/component"
4
+
3
5
  module Weft
4
6
  module Defaults
5
7
  # Gem-default component rendered when a routing miss falls through to
@@ -8,12 +10,12 @@ module Weft
8
10
  class NotFoundComponent < Weft::Component
9
11
  abstract!
10
12
 
11
- # Opt into the :component_id auto-injected attribute for parity with
12
- # ErrorComponentpreserves DOM identity when a component-context
13
- # NotFound recovers through this.
14
- attribute :component_id
15
- attribute :request_path
16
- attribute :status_code
13
+ # Auto-injected params, opt-in for parity with ErrorComponent. Identity
14
+ # is not among them the Router stamps the failing component's DOM id
15
+ # onto every recovery fragment itself.
16
+ param :component_tag, type: :string
17
+ param :request_path, type: :string
18
+ param :status_code, type: :integer
17
19
 
18
20
  STYLE = "padding:1rem; border:1px solid #cbd5e1; border-radius:6px; " \
19
21
  "background:#f8fafc; color:#0f172a; font-size:0.875rem"
@@ -25,19 +27,19 @@ module Weft
25
27
  set_attribute "style", STYLE
26
28
 
27
29
  div(style: "font-weight:600") { text_node "Not found" }
28
- render_verbose if Weft.configuration.verbose_error_pages && @attrs.request_path
30
+ render_verbose if Weft.configuration.verbose_error_pages && @params.request_path
29
31
  end
30
32
 
31
- # Preserve the failing component's DOM identity when the Router injected
32
- # :component_id. Otherwise fall back to the class-derived default.
33
- def weft_id
34
- @attrs.component_id || super
33
+ # Adopt the failing component's wrapper tag when the Router injected
34
+ # :component_tag (see ErrorComponent#tag_name).
35
+ def tag_name
36
+ @params.component_tag || super
35
37
  end
36
38
 
37
39
  private
38
40
 
39
41
  def render_verbose
40
- div(style: MONO_STYLE) { text_node @attrs.request_path.to_s }
42
+ div(style: MONO_STYLE) { text_node @params.request_path.to_s }
41
43
  end
42
44
  end
43
45
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/page"
4
+
3
5
  module Weft
4
6
  module Defaults
5
7
  # Gem-default full-document page rendered for traditional (non-htmx)
@@ -8,17 +10,16 @@ module Weft
8
10
  class NotFoundPage < Weft::Page
9
11
  self.page_path = "/_weft/not_found"
10
12
 
11
- attribute :request_path
12
- attribute :status_code
13
+ param :request_path, type: :string
14
+ param :status_code, type: :integer
15
+
16
+ title "Not found"
13
17
 
18
+ # See ErrorPage#build: the component resolves these from the same
19
+ # request, so passing them here would only paint them onto the wrapper.
14
20
  def build(attributes = {})
15
- attributes[:title] ||= "Not found"
16
21
  super
17
- insert_tag(
18
- Weft::Defaults::NotFoundComponent,
19
- request_path: @attrs.request_path,
20
- status_code: @attrs.status_code
21
- )
22
+ insert_tag(Weft::Defaults::NotFoundComponent)
22
23
  end
23
24
  end
24
25
  end