weft 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
data/lib/weft/dsl/actions.rb
CHANGED
|
@@ -12,12 +12,12 @@ module Weft
|
|
|
12
12
|
module ClassMethods
|
|
13
13
|
# Declare a user-initiated action on this component.
|
|
14
14
|
#
|
|
15
|
-
# performs :advance do |
|
|
16
|
-
# order = Order.find(
|
|
15
|
+
# performs :advance do |params|
|
|
16
|
+
# order = Order.find(params.order_id)
|
|
17
17
|
# Oms::PrepareOrder.call(order)
|
|
18
18
|
# end
|
|
19
19
|
#
|
|
20
|
-
# performs method: :delete, swap: :delete do |
|
|
20
|
+
# performs method: :delete, swap: :delete do |params| ... end
|
|
21
21
|
def performs(name = nil, method: :post, swap: :outer_html, target: nil, &block)
|
|
22
22
|
action = Weft::Action.new(name: name, method: method, swap: swap,
|
|
23
23
|
target: target, renders: self, callable: block)
|
|
@@ -26,19 +26,19 @@ module Weft
|
|
|
26
26
|
|
|
27
27
|
# Sugar for performs with swap: :delete. Removes the component from
|
|
28
28
|
# the DOM on success. The callable (if given) runs for side effects;
|
|
29
|
-
# the
|
|
29
|
+
# the success response carries no body (OOB includes still ride).
|
|
30
30
|
#
|
|
31
31
|
# dismisses :close # no side effects
|
|
32
|
-
# dismisses :remove do |
|
|
33
|
-
# Item.find(
|
|
32
|
+
# dismisses :remove do |params| # with side effects
|
|
33
|
+
# Item.find(params.item_id).archive!
|
|
34
34
|
# end
|
|
35
|
-
def dismisses(name = nil, method: :delete, &)
|
|
36
|
-
performs(name, method: method, swap: :delete, &)
|
|
35
|
+
def dismisses(name = nil, method: :delete, target: nil, &)
|
|
36
|
+
performs(name, method: method, swap: :delete, target: target, &)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Declare a transfer — an action that renders a different component.
|
|
40
40
|
#
|
|
41
|
-
# transfers :edit, to: EditableOrderHeader do |
|
|
41
|
+
# transfers :edit, to: EditableOrderHeader do |params|
|
|
42
42
|
# { mode: "full" }
|
|
43
43
|
# end
|
|
44
44
|
def transfers(name = nil, to:, method: :post, swap: :outer_html, target: nil, &block)
|
data/lib/weft/dsl/inclusions.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "weft/error"
|
|
4
|
+
|
|
3
5
|
module Weft
|
|
4
6
|
module DSL
|
|
5
7
|
# Mixin for classes that declare OOB-swapped sibling components.
|
|
@@ -12,21 +14,42 @@ module Weft
|
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
module ClassMethods
|
|
17
|
+
# Contexts a `when:` filter may name. Only :transferred ships today;
|
|
18
|
+
# the vocabulary grows deliberately.
|
|
19
|
+
WHEN_VALUES = %i[transferred].freeze
|
|
20
|
+
|
|
15
21
|
# Declare that another component should be OOB-swapped alongside
|
|
16
|
-
# this component's
|
|
22
|
+
# this component's responses.
|
|
17
23
|
#
|
|
18
|
-
# includes OrderHeader #
|
|
19
|
-
# includes OrderHeader, on: :advance #
|
|
20
|
-
# includes OrderHeader
|
|
21
|
-
#
|
|
24
|
+
# includes OrderHeader # every response
|
|
25
|
+
# includes OrderHeader, on: :advance # own action(s) only
|
|
26
|
+
# includes OrderHeader, on: %i[advance retreat]
|
|
27
|
+
# includes OrderHeader, when: :transferred # transfer arrivals only
|
|
28
|
+
# includes OrderHeader, on: :save, when: :transferred # union: either
|
|
29
|
+
# includes OrderHeader do |params| # companion delta
|
|
30
|
+
# { order_id: params.order_id, compact: true }
|
|
22
31
|
# end
|
|
23
32
|
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
# Unfiltered inclusions ride every response this component renders in:
|
|
34
|
+
# its own actions, SSE pushes, and transfer arrivals. Filters
|
|
35
|
+
# enumerate contexts — `on:` names this component's OWN actions
|
|
36
|
+
# (never a transferring component's), `when: :transferred` fires when
|
|
37
|
+
# this component renders as a transfer target; declaring both is a
|
|
38
|
+
# union. The block's return is a DELTA overlaid on the request for
|
|
39
|
+
# this companion alone; blockless is exactly an empty delta.
|
|
40
|
+
#
|
|
41
|
+
# (`when` rides **options because it's a Ruby reserved word — call
|
|
42
|
+
# sites are unaffected, only this signature is.)
|
|
43
|
+
def includes(component_class, on: nil, **options, &block)
|
|
44
|
+
when_filter = options.delete(:when)
|
|
45
|
+
raise ArgumentError, "unknown keywords: #{options.keys.inspect}" unless options.empty?
|
|
46
|
+
|
|
47
|
+
site = caller_locations(1, 1).first
|
|
48
|
+
own_inclusions << { component_class: component_class,
|
|
49
|
+
on: on.nil? ? nil : Array(on),
|
|
50
|
+
when: validated_when(when_filter),
|
|
51
|
+
block: block,
|
|
52
|
+
source_location: [site.path, site.lineno] }
|
|
30
53
|
end
|
|
31
54
|
|
|
32
55
|
# All declared inclusions (own + inherited).
|
|
@@ -40,6 +63,20 @@ module Weft
|
|
|
40
63
|
|
|
41
64
|
private
|
|
42
65
|
|
|
66
|
+
def validated_when(value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
contexts = Array(value)
|
|
70
|
+
unknown = contexts - WHEN_VALUES
|
|
71
|
+
unless unknown.empty?
|
|
72
|
+
raise Weft::InvalidDefinition,
|
|
73
|
+
"includes when: #{unknown.map(&:inspect).join(', ')} names no known context — " \
|
|
74
|
+
"recognized: #{WHEN_VALUES.map(&:inspect).join(', ')}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
contexts
|
|
78
|
+
end
|
|
79
|
+
|
|
43
80
|
def own_inclusions
|
|
44
81
|
@own_inclusions ||= []
|
|
45
82
|
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weft/error"
|
|
4
|
+
require "weft/params/assembly"
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
module DSL
|
|
8
|
+
# Mixin for classes that declare consumed inputs — the doors into `params`.
|
|
9
|
+
# Provides the `param` (wire), `receives` (caller hand-off), and `derives`
|
|
10
|
+
# (lazy server-side derivation) class DSL and the `params` instance reader.
|
|
11
|
+
# Used by Component (for partial route params) and Page (for page route params).
|
|
12
|
+
module Params
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module ClassMethods
|
|
18
|
+
# Declare a wire param. `default:` fills the key when no source
|
|
19
|
+
# supplies it; `type:` coerces the wire's string into the declared
|
|
20
|
+
# type (see Resolver::TYPES). The two are orthogonal — an untyped
|
|
21
|
+
# param accepts any value uncoerced — but a non-nil default must
|
|
22
|
+
# already be an instance of the declared type.
|
|
23
|
+
# param :page, type: :integer
|
|
24
|
+
# param :status, default: "active", type: :string
|
|
25
|
+
def param(name, default: nil, type: nil)
|
|
26
|
+
validate_type!(name, type, default) unless type.nil?
|
|
27
|
+
meta = { default: default }
|
|
28
|
+
meta[:type] = type unless type.nil?
|
|
29
|
+
own_params[name] = meta
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns all declared params (own + inherited), preserving declaration order.
|
|
33
|
+
def params
|
|
34
|
+
if superclass.respond_to?(:params)
|
|
35
|
+
superclass.params.merge(own_params)
|
|
36
|
+
else
|
|
37
|
+
own_params.dup
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Declare a hand-off param: the caller provides the value as a builder
|
|
42
|
+
# kwarg at the call site; it lands in `params`, never in HTML chrome.
|
|
43
|
+
# receives :order # required — absence raises
|
|
44
|
+
# receives :page_num, default: 1 # optional — any declared default
|
|
45
|
+
# # (even nil) softens absence
|
|
46
|
+
# Hand-offs are server-side values: they never serialize into URLs and
|
|
47
|
+
# don't make a component routable.
|
|
48
|
+
def receives(name, **options)
|
|
49
|
+
meta = {}
|
|
50
|
+
meta[:default] = options[:default] if options.key?(:default)
|
|
51
|
+
own_received_params[name] = meta
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# All declared hand-offs (own + inherited), preserving declaration
|
|
55
|
+
# order. Kept separate from `params` — the wire door and the hand-off
|
|
56
|
+
# door differ in serialization and routability, even for dual keys.
|
|
57
|
+
def received_params
|
|
58
|
+
if superclass.respond_to?(:received_params)
|
|
59
|
+
superclass.received_params.merge(own_received_params)
|
|
60
|
+
else
|
|
61
|
+
own_received_params.dup
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Declare a lazy server-side derivation: the block runs (at most once
|
|
66
|
+
# per render) when `params.name` is first read, never if it isn't.
|
|
67
|
+
# derives(:order) { |params| Order.find(params.order_id) }
|
|
68
|
+
# The block is a `(params) -> value` pure function with a void self.
|
|
69
|
+
# Derived values are server-side: never serialized, not
|
|
70
|
+
# routable-making.
|
|
71
|
+
def derives(name, &block)
|
|
72
|
+
unless block
|
|
73
|
+
raise Weft::InvalidDefinition,
|
|
74
|
+
"derives #{name.inspect} requires a block — the derivation is the declaration"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
own_derived_params[name] = { block: block, source_location: block.source_location }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Sugar for statically-known derivations: each pair registers
|
|
81
|
+
# `derives(key) { value }`. This is just `derives` — identical
|
|
82
|
+
# priority, overridability, and laziness; only the value is fixed at
|
|
83
|
+
# declaration. For anything computed per render (queries, clocks),
|
|
84
|
+
# use `derives` — an interpolated value here would freeze at
|
|
85
|
+
# class-load time.
|
|
86
|
+
# defines label: "Drivers", accent: "available"
|
|
87
|
+
def defines(pairs)
|
|
88
|
+
site = caller_locations(1, 1).first
|
|
89
|
+
pairs.each do |name, value|
|
|
90
|
+
own_derived_params[name] = { block: proc { |_p| value },
|
|
91
|
+
source_location: [site.path, site.lineno] }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Every key this class declares, whichever door it came through —
|
|
96
|
+
# what a bag assembled for this class will hold entries for.
|
|
97
|
+
def declared_keys
|
|
98
|
+
params.keys | received_params.keys | derived_params.keys
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# All declared derivations (own + inherited), preserving declaration
|
|
102
|
+
# order. A child redeclaring a parent's key replaces the block, like
|
|
103
|
+
# a method override.
|
|
104
|
+
def derived_params
|
|
105
|
+
if superclass.respond_to?(:derived_params)
|
|
106
|
+
superclass.derived_params.merge(own_derived_params)
|
|
107
|
+
else
|
|
108
|
+
own_derived_params.dup
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def validate_type!(name, type, default)
|
|
115
|
+
entry = Weft::Resolver::TYPES[type]
|
|
116
|
+
unless entry
|
|
117
|
+
raise Weft::InvalidDefinition,
|
|
118
|
+
"param #{name.inspect} declares unknown type #{type.inspect} — declarable " \
|
|
119
|
+
"types are #{Weft::Resolver::TYPES.keys.map(&:inspect).join(', ')}"
|
|
120
|
+
end
|
|
121
|
+
return if default.nil? || entry[:classes].any? { |klass| default.is_a?(klass) }
|
|
122
|
+
|
|
123
|
+
raise Weft::InvalidDefinition,
|
|
124
|
+
"param #{name.inspect} declares type #{type.inspect} but its default " \
|
|
125
|
+
"#{default.inspect} is #{default.class} — make them agree, or drop one"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def own_params
|
|
129
|
+
@own_params ||= {}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def own_received_params
|
|
133
|
+
@own_received_params ||= {}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def own_derived_params
|
|
137
|
+
@own_derived_params ||= {}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# One-time chrome-collision warnings, keyed [class, key].
|
|
142
|
+
# See #warn_declared_chrome_collisions.
|
|
143
|
+
def self.warned_collisions
|
|
144
|
+
@warned_collisions ||= Set.new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Instance access to the resolved bag.
|
|
148
|
+
# Returns a Weft::Params object with method-style and hash access.
|
|
149
|
+
attr_reader :params
|
|
150
|
+
|
|
151
|
+
# @api private
|
|
152
|
+
# The bag projected onto this class's own declared wire schema — the
|
|
153
|
+
# only slice that serializes (refresh/stream URLs, DOM ids, hx-vals).
|
|
154
|
+
# Hand-offs and inherited values are server-side and never ride the
|
|
155
|
+
# wire. Per-key reads, NOT to_h: serialization must never materialize
|
|
156
|
+
# non-wire derivations (a thunk on a wire-schema key — the rare
|
|
157
|
+
# param+derives dual — does force here; the refresh contract wins).
|
|
158
|
+
def serializable_params
|
|
159
|
+
return {} unless params
|
|
160
|
+
|
|
161
|
+
self.class.params.keys.to_h { |key| [key, params[key]] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Assemble the bag per the source stack: staged hand-off > own wire
|
|
167
|
+
# value > inherited bag value > declared default. Staging only happens
|
|
168
|
+
# under Weft::Context; in a plain Arbre context the hand-off door is a
|
|
169
|
+
# build-top fallback instead, so hand-off validation waits for it there.
|
|
170
|
+
def assembled_params
|
|
171
|
+
if arbre_context.respond_to?(:take_received!)
|
|
172
|
+
resolve_bag(received: arbre_context.take_received!(self.class) || {}, validate: true)
|
|
173
|
+
else
|
|
174
|
+
resolve_bag(received: {}, validate: false)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def resolve_bag(received:, validate:)
|
|
179
|
+
bag = Weft::Params::Assembly.call(self.class, wire_source,
|
|
180
|
+
hand_offs: received,
|
|
181
|
+
overlays: context_overlays, branched_from: inherited_bag)
|
|
182
|
+
validate_hand_offs!(bag) if validate
|
|
183
|
+
bag
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def context_overlays
|
|
187
|
+
arbre_context.respond_to?(:overlays) ? arbre_context.overlays : {}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Checks required_hand_off? before reading the key: a required hand-off
|
|
191
|
+
# is receives-only (never thunked), so the read can't force anything —
|
|
192
|
+
# and dual keys short-circuit without touching their lazy derivation.
|
|
193
|
+
def validate_hand_offs!(bag)
|
|
194
|
+
self.class.received_params.each_key do |key|
|
|
195
|
+
raise_not_received!(key) if required_hand_off?(key) && bag[key].nil?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def wire_source
|
|
200
|
+
arbre_context.respond_to?(:wire_params) ? arbre_context.wire_params : {}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Branch a copy of the nearest tree-ancestor's bag — the in-page
|
|
204
|
+
# parent-child axis: a component sees everything above it, nothing
|
|
205
|
+
# beside it. At construction the current element IS the future parent,
|
|
206
|
+
# so the walk works before the tree links this instance in. Returns
|
|
207
|
+
# [data, provenance]; the copy is thunk-preserving (never forces the
|
|
208
|
+
# ancestor's lazy entries) and nil-dropping. A root with no tree
|
|
209
|
+
# ancestor falls back to the context's branch bag — how an OOB
|
|
210
|
+
# companion inherits from the primary it rides alongside, and how the
|
|
211
|
+
# state a request has already composed reaches the component it renders.
|
|
212
|
+
def inherited_bag
|
|
213
|
+
el = arbre_context.current_arbre_element
|
|
214
|
+
while el
|
|
215
|
+
return el.params if el.is_a?(Weft::DSL::Params) && el.params
|
|
216
|
+
|
|
217
|
+
el = el.parent
|
|
218
|
+
end
|
|
219
|
+
arbre_context.respond_to?(:branch_bag) ? arbre_context.branch_bag : nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A hand-off is required when `receives` is its only door and no
|
|
223
|
+
# default was declared — nothing else can satisfy the presumption.
|
|
224
|
+
def required_hand_off?(key)
|
|
225
|
+
meta = self.class.received_params[key]
|
|
226
|
+
meta && !meta.key?(:default) && !self.class.params.key?(key)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def raise_not_received!(key)
|
|
230
|
+
raise Weft::NotReceived,
|
|
231
|
+
"#{self.class.name} expects to receive #{key.inspect}: pass it as a builder kwarg " \
|
|
232
|
+
"at the call site, or declare a default: to make it optional"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Build-top fallback for the hand-off door in plain Arbre contexts,
|
|
236
|
+
# where interception never runs: pull receives-declared kwargs out of
|
|
237
|
+
# the attributes hash (they're hand-offs, not chrome), overlay them on
|
|
238
|
+
# the bag, and run the validation construction had to defer. Handed
|
|
239
|
+
# nil counts as absence, like everywhere else in the stack.
|
|
240
|
+
def apply_received_fallback(attributes)
|
|
241
|
+
keys = self.class.received_params.keys & attributes.keys
|
|
242
|
+
handed = keys.to_h { |k| [k, attributes.delete(k)] }
|
|
243
|
+
@params = @params.overlay(handed.compact)
|
|
244
|
+
validate_hand_offs!(@params)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A builder kwarg naming a declared param renders as an HTML attribute
|
|
248
|
+
# only (params arrive from the wire, not the call site). Warn once per
|
|
249
|
+
# (class, key): param names legitimately collide with HTML attribute
|
|
250
|
+
# names (height, title, size, ...), so a standing collision shouldn't
|
|
251
|
+
# spam every render. Set#add? races just double-warn; harmless.
|
|
252
|
+
def warn_declared_chrome_collisions(attributes)
|
|
253
|
+
attributes.each_key do |key|
|
|
254
|
+
next unless self.class.params.key?(key)
|
|
255
|
+
next unless Weft::DSL::Params.warned_collisions.add?([self.class, key])
|
|
256
|
+
|
|
257
|
+
Weft.logger.warn(
|
|
258
|
+
"#{self.class.name}: builder kwarg #{key.inspect} matches a declared param and " \
|
|
259
|
+
"renders as an HTML attribute only (params arrive from the wire, not the call site)"
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
data/lib/weft/dsl/recoveries.rb
CHANGED
|
@@ -15,22 +15,27 @@ module Weft
|
|
|
15
15
|
module ClassMethods
|
|
16
16
|
# Declare a recovery edge: how this class handles a specific error.
|
|
17
17
|
#
|
|
18
|
-
# recovers from: Weft::Unprocessable do |
|
|
18
|
+
# recovers from: Weft::Unprocessable do |params, error|
|
|
19
19
|
# { error_messages: error.messages }
|
|
20
20
|
# end
|
|
21
21
|
#
|
|
22
22
|
# recovers from: Weft::Unauthorized, with: LoginPage
|
|
23
|
+
# recovers from: ActiveRecord::RecordNotFound, with: NotFoundPage, status: 404
|
|
23
24
|
#
|
|
24
25
|
# `from:` accepts a Class (subclass-inclusive), Integer (matched against
|
|
25
26
|
# HTTPError#status), Range, or Array of any of the above.
|
|
26
27
|
# `with:` accepts a Class (Page or Component) or Symbol (resolved against
|
|
27
28
|
# Weft.configuration at error-handling time). Default: self.
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
29
|
+
# `status:` declares what the error means on the wire — recoveries from
|
|
30
|
+
# errors that aren't Weft::HTTPErrors report 500 without it. Must be an
|
|
31
|
+
# HTTP error status (400..599); raises Weft::InvalidUsage otherwise.
|
|
32
|
+
# The optional block receives `|params, error|` and returns a hash of
|
|
33
|
+
# additional params that merge with the original on the recovery edge.
|
|
34
|
+
# Symmetric with performs/transfers contracts (params first; error is the
|
|
31
35
|
# recovery-specific extra). The block never returns HTML.
|
|
32
|
-
def recovers(from:, with: nil, &block)
|
|
33
|
-
|
|
36
|
+
def recovers(from:, with: nil, status: nil, &block)
|
|
37
|
+
validate_recovery_status!(status)
|
|
38
|
+
own_recoveries << { from: from, with: with, status: status, block: block }
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
# All declared recovery entries (own + inherited), in resolution order.
|
|
@@ -53,6 +58,18 @@ module Weft
|
|
|
53
58
|
recoveries.find { |entry| recovery_matches?(entry[:from], exception) }
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# Like recovery_for, but skips entries whose target resolves to a Page —
|
|
62
|
+
# for contexts that can only render component fragments (an SSE push
|
|
63
|
+
# can't redirect or full-page swap). The gem-default StandardError
|
|
64
|
+
# entry at the bottom of every component chain resolves to a component,
|
|
65
|
+
# so StandardError-family exceptions always find a match.
|
|
66
|
+
def component_recovery_for(exception)
|
|
67
|
+
recoveries.find do |entry|
|
|
68
|
+
recovery_matches?(entry[:from], exception) &&
|
|
69
|
+
!page_recovery_target?(resolve_recovery_target(entry))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
56
73
|
# Resolve the recovery entry's `with:` value to a concrete target class.
|
|
57
74
|
# Symbol values look up `Weft.configuration.<sym>` (resolved at error-handling
|
|
58
75
|
# time so config reassignment propagates). Nil falls back to self.
|
|
@@ -70,6 +87,15 @@ module Weft
|
|
|
70
87
|
@own_recoveries ||= []
|
|
71
88
|
end
|
|
72
89
|
|
|
90
|
+
# Declarations raise (a bad status is a coding bug, visible at load);
|
|
91
|
+
# only error semantics are assignable — a recovery can't claim success.
|
|
92
|
+
def validate_recovery_status!(status)
|
|
93
|
+
return if status.nil? || (status.is_a?(Integer) && (400..599).cover?(status))
|
|
94
|
+
|
|
95
|
+
raise Weft::InvalidUsage,
|
|
96
|
+
"recovers status: must be an HTTP error status (400..599); got #{status.inspect}"
|
|
97
|
+
end
|
|
98
|
+
|
|
73
99
|
def recovery_matches?(from_clause, exception)
|
|
74
100
|
case from_clause
|
|
75
101
|
when Array then from_clause.any? { |f| recovery_matches?(f, exception) }
|
|
@@ -83,6 +109,10 @@ module Weft
|
|
|
83
109
|
def recovery_status_of(exception)
|
|
84
110
|
exception.is_a?(Weft::HTTPError) ? exception.status : 500
|
|
85
111
|
end
|
|
112
|
+
|
|
113
|
+
def page_recovery_target?(target)
|
|
114
|
+
target.is_a?(Class) && defined?(Weft::Page) && target <= Weft::Page
|
|
115
|
+
end
|
|
86
116
|
end
|
|
87
117
|
end
|
|
88
118
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
module DSL
|
|
5
|
+
# The `self` a verb block runs against — a "void context": empty of
|
|
6
|
+
# anything component-specific, so a block cannot reach local state and is
|
|
7
|
+
# portable to any process. Verb blocks (`derives`, `performs`, `transfers`,
|
|
8
|
+
# `recovers`, `includes`) are `(params) -> value` pure functions: their
|
|
9
|
+
# arguments and return value are explicit, constants resolve lexically, and
|
|
10
|
+
# Kernel stays reachable (raise, format, Integer()).
|
|
11
|
+
#
|
|
12
|
+
# Each execution runs in a fresh instance, dropped once the return value is
|
|
13
|
+
# captured — so a block may use scratch instance variables freely, but that
|
|
14
|
+
# scratch never leaks past its own execution. Instances are deliberately
|
|
15
|
+
# unfrozen (scratch is allowed) yet non-leaking (freshness, not freezing,
|
|
16
|
+
# is what isolates them); a future +register_helpers+ facility would add
|
|
17
|
+
# vocabulary here without changing that contract.
|
|
18
|
+
class Sandbox
|
|
19
|
+
# Run a block in a fresh sandbox and return its value. Extra arguments
|
|
20
|
+
# after +params+ ride through to the block (recovery blocks take
|
|
21
|
+
# +(params, error)+). Each call gets its own instance by construction, so
|
|
22
|
+
# no caller can reuse one across executions.
|
|
23
|
+
def self.run(...) = new.instance_exec(...)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/weft/dsl/triggers.rb
CHANGED
|
@@ -12,23 +12,43 @@ module Weft
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
module ClassMethods
|
|
15
|
-
# Declare an event
|
|
15
|
+
# Declare an event this component announces on its action responses.
|
|
16
16
|
# Sets the HX-Trigger response header.
|
|
17
17
|
#
|
|
18
|
-
# triggers "order-updated"
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
# triggers "order-updated" # every action
|
|
19
|
+
# triggers "order-updated", on: :advance # that action only
|
|
20
|
+
# triggers "moved", on: %i[advance retreat]
|
|
21
|
+
#
|
|
22
|
+
# `on:` maps the event to the actions it belongs to. Without it an
|
|
23
|
+
# event is welded to every action the component has, which is rarely
|
|
24
|
+
# what a component with more than one action means.
|
|
25
|
+
#
|
|
26
|
+
# There is deliberately no `when:` counterpart: HX-Trigger announces
|
|
27
|
+
# what a callable did, so a render-context filter has nothing to say
|
|
28
|
+
# about it.
|
|
29
|
+
def triggers(event_name, on: nil)
|
|
30
|
+
own_triggers << { event: event_name.to_s, on: on.nil? ? nil : Array(on) }
|
|
21
31
|
end
|
|
22
32
|
|
|
23
|
-
# All declared trigger
|
|
24
|
-
def
|
|
25
|
-
if superclass.respond_to?(:
|
|
26
|
-
superclass.
|
|
33
|
+
# All declared trigger entries (own + inherited), ancestry first.
|
|
34
|
+
def trigger_declarations
|
|
35
|
+
if superclass.respond_to?(:trigger_declarations)
|
|
36
|
+
superclass.trigger_declarations + own_triggers
|
|
27
37
|
else
|
|
28
38
|
own_triggers.dup
|
|
29
39
|
end
|
|
30
40
|
end
|
|
31
41
|
|
|
42
|
+
# The event names that fire for one action. Unfiltered declarations
|
|
43
|
+
# fire on every action, including the nameless one; a subclass can
|
|
44
|
+
# therefore widen an inherited filter by redeclaring the event
|
|
45
|
+
# unfiltered, and duplicates collapse.
|
|
46
|
+
def trigger_events(action_name = nil)
|
|
47
|
+
trigger_declarations.
|
|
48
|
+
select { |trigger| trigger[:on].nil? || trigger[:on].include?(action_name) }.
|
|
49
|
+
map { |trigger| trigger[:event] }.uniq
|
|
50
|
+
end
|
|
51
|
+
|
|
32
52
|
private
|
|
33
53
|
|
|
34
54
|
def own_triggers
|
data/lib/weft/dsl/updates.rb
CHANGED
|
@@ -30,19 +30,30 @@ module Weft
|
|
|
30
30
|
|
|
31
31
|
# Declare that this component pushes updates via SSE.
|
|
32
32
|
#
|
|
33
|
-
# pushes every: 5.seconds
|
|
33
|
+
# pushes every: 5.seconds # server pushes on interval
|
|
34
|
+
# pushes every: 5.seconds, attempts: 5 # custom failure budget
|
|
35
|
+
# pushes every: 5.seconds, immediate: false # wait a full interval first
|
|
34
36
|
#
|
|
35
|
-
# Generates hx-ext="sse", sse-connect, sse-swap on the
|
|
36
|
-
# The Router auto-generates a streaming endpoint at
|
|
37
|
+
# Generates hx-ext="sse", sse-connect, sse-swap, sse-close on the
|
|
38
|
+
# wrapper element. The Router auto-generates a streaming endpoint at
|
|
37
39
|
# /component_path/_stream (the suffix is the stream_suffix config knob).
|
|
40
|
+
# `attempts:` overrides Weft.configuration.push_attempts — consecutive
|
|
41
|
+
# failed pushes tolerated before the Router closes the stream.
|
|
42
|
+
# `immediate:` defaults to true (new subscribers get a state snapshot
|
|
43
|
+
# right away); `immediate: false` opts back into polling-cadence
|
|
44
|
+
# semantics — first frame after one interval — for components whose
|
|
45
|
+
# current state would mislead a fresh subscriber.
|
|
38
46
|
#
|
|
39
47
|
# Future: pushes on: "event-name" for event-driven server push (v1.0).
|
|
40
|
-
def pushes(every: nil)
|
|
48
|
+
def pushes(every: nil, attempts: nil, immediate: nil)
|
|
41
49
|
@push_config = {}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
if every
|
|
51
|
+
ms = interval_in_ms(every, :pushes)
|
|
52
|
+
@push_config[:every] = (ms % 1000).zero? ? ms / 1000 : ms / 1000.0
|
|
53
|
+
end
|
|
54
|
+
@push_config[:attempts] = validated_attempts(attempts) if attempts
|
|
55
|
+
# nil-guarded, not truthiness: `immediate: false` must persist.
|
|
56
|
+
@push_config[:immediate] = immediate unless immediate.nil?
|
|
46
57
|
end
|
|
47
58
|
|
|
48
59
|
# Push configuration (own or inherited). Returns nil if no pushes declared.
|
|
@@ -69,6 +80,18 @@ module Weft
|
|
|
69
80
|
@own_refresh_triggers ||= []
|
|
70
81
|
end
|
|
71
82
|
|
|
83
|
+
# A budget below one failed push makes no sense — the stream would
|
|
84
|
+
# close before its first recovery frame; rather than raise
|
|
85
|
+
# mid-declaration, clamp and say so.
|
|
86
|
+
def validated_attempts(attempts)
|
|
87
|
+
return attempts if attempts.is_a?(Integer) && attempts >= 1
|
|
88
|
+
|
|
89
|
+
Weft.logger.warn(
|
|
90
|
+
"#{name} declares `pushes attempts: #{attempts.inspect}`, not a positive integer; using 1"
|
|
91
|
+
)
|
|
92
|
+
1
|
|
93
|
+
end
|
|
94
|
+
|
|
72
95
|
# htmx's smallest expressible interval is 1ms; rather than emit an
|
|
73
96
|
# "every 0s" that polls flat-out, round up and say so.
|
|
74
97
|
def interval_in_ms(every, verb)
|
data/lib/weft/error.rb
CHANGED
|
@@ -48,7 +48,7 @@ module Weft
|
|
|
48
48
|
InvalidConfiguration = Class.new(Error)
|
|
49
49
|
|
|
50
50
|
# Raised for semantic mistakes in class-body DSL declarations — e.g. a page
|
|
51
|
-
# declares
|
|
51
|
+
# declares params but no `page_path`, or `adds_children_to` receives a
|
|
52
52
|
# Symbol that does not start with `@`.
|
|
53
53
|
InvalidDefinition = Class.new(Error)
|
|
54
54
|
|
|
@@ -57,8 +57,19 @@ module Weft
|
|
|
57
57
|
# assets bundle named at `register_stylesheet`).
|
|
58
58
|
InvalidUsage = Class.new(Error)
|
|
59
59
|
|
|
60
|
+
# Raised by the bang forms of the render-tree lookup (`closest!` / `enclosing!`)
|
|
61
|
+
# when no node matches the requested criteria — the component expected an
|
|
62
|
+
# ancestor that isn't there (it should usually be `dependent!` and rendered
|
|
63
|
+
# only inside that ancestor).
|
|
64
|
+
AncestorNotFound = Class.new(InvalidUsage)
|
|
65
|
+
|
|
60
66
|
# Raised by the `adds_children_to :@ivar` macro when build returns without
|
|
61
67
|
# ever assigning the named ivar and then a child is added — almost always
|
|
62
68
|
# means the developer declared the macro but forgot the matching assignment.
|
|
63
69
|
MissingContainerIvar = Class.new(InvalidDefinition)
|
|
70
|
+
|
|
71
|
+
# Raised at component construction when a `receives` key with no declared
|
|
72
|
+
# default ends resolution valueless — the call site didn't hand it over and
|
|
73
|
+
# no other source (wire dual, inherited bag) supplied it.
|
|
74
|
+
NotReceived = Class.new(InvalidUsage)
|
|
64
75
|
end
|