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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weft/params"
|
|
4
|
+
require "weft/resolver"
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
class Params
|
|
8
|
+
# Composes a params bag from every source that can supply one. The single
|
|
9
|
+
# place that knows the source stack — components call it at construction
|
|
10
|
+
# for their own bag, and the Router calls it at the top of a request for
|
|
11
|
+
# the bag it hands the first verb block.
|
|
12
|
+
#
|
|
13
|
+
# The stack, top wins. `nil` never wins a level: it means that source
|
|
14
|
+
# didn't have the key.
|
|
15
|
+
#
|
|
16
|
+
# 1. hand-off a caller staged a value for this component (`receives`)
|
|
17
|
+
# 2. overlay a verb block earlier in this request returned the key
|
|
18
|
+
# 3. own wire the request supplied it and this class declares it
|
|
19
|
+
# 4. inherited the bag this one branched from had it
|
|
20
|
+
# 5. derivation this class declares one (registered lazily, never run here)
|
|
21
|
+
# 6. default the declaration's own fallback
|
|
22
|
+
#
|
|
23
|
+
# The overlay speaks *as* the wire: its value replaces the wire's for that
|
|
24
|
+
# key, and an explicit nil clears — masking the wire so resolution falls
|
|
25
|
+
# below it. A derivation always "produces" (a thunk is never nil), so a
|
|
26
|
+
# same-key default sits unreachable behind one.
|
|
27
|
+
class Assembly
|
|
28
|
+
class << self
|
|
29
|
+
def call(...) = new(...).bag
|
|
30
|
+
|
|
31
|
+
# The state a request composes before any component of its own exists —
|
|
32
|
+
# what the first verb block sees. No caller and no enclosing build have
|
|
33
|
+
# run, so the hand-off door isn't merely unsatisfied, it isn't there:
|
|
34
|
+
# a `receives`-only key is absent, declared default and all. Dual it
|
|
35
|
+
# with a `param` or a `defines` to make the key visible here.
|
|
36
|
+
def for_request(component_class, wire_source)
|
|
37
|
+
call(component_class, wire_source, hand_offs: nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One-time shadowing warnings, keyed [kind, class, key]. Set#add? races
|
|
41
|
+
# just double-warn; harmless.
|
|
42
|
+
def warned = @warned ||= Set.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# +branched_from+ is the bag this one inherits: a tree ancestor's during
|
|
46
|
+
# a render, the state already composed at the top of a request. Passing
|
|
47
|
+
# +hand_offs: nil+ says the door does not exist (see .for_request);
|
|
48
|
+
# an empty hash says it exists and nobody staged anything.
|
|
49
|
+
def initialize(component_class, wire_source, hand_offs: {}, overlays: {}, branched_from: nil)
|
|
50
|
+
@component_class = component_class
|
|
51
|
+
@received = hand_offs || {}
|
|
52
|
+
@hand_offs = !hand_offs.nil?
|
|
53
|
+
@overlays = overlays
|
|
54
|
+
@wire = Weft::Resolver.resolve_present(component_class, wire_source)
|
|
55
|
+
@inherited = branched_from ? branched_from.branch_data : {}
|
|
56
|
+
@upstream_provenance = branched_from ? branched_from.provenance : {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bag
|
|
60
|
+
data = @inherited.dup
|
|
61
|
+
keys.each { |key| data[key] = stack_value(key) }
|
|
62
|
+
report_shadowed_derivations(data)
|
|
63
|
+
Weft::Params.new(data, provenance, defaults: declared_defaults)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def keys
|
|
69
|
+
return @component_class.declared_keys if @hand_offs
|
|
70
|
+
|
|
71
|
+
@component_class.declared_keys - hand_off_only_keys
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Keys whose only door is `receives`. A dual key is declared on another
|
|
75
|
+
# door too, so it stands on its own without a caller.
|
|
76
|
+
def hand_off_only_keys
|
|
77
|
+
@component_class.received_params.keys - @component_class.params.keys -
|
|
78
|
+
@component_class.derived_params.keys
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stack_value(key)
|
|
82
|
+
return @received[key] unless @received[key].nil?
|
|
83
|
+
|
|
84
|
+
wire_level = @overlays.key?(key) ? @overlays[key] : @wire[key]
|
|
85
|
+
[wire_level, @inherited[key], derived_thunk(key)].find { |v| !v.nil? }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Fallbacks, not values: they ride on the bag rather than in it, so a
|
|
89
|
+
# key nobody supplied reads as this class's default without becoming
|
|
90
|
+
# something this class hands to anyone downstream.
|
|
91
|
+
def declared_defaults
|
|
92
|
+
keys.filter_map { |key| [key, default_for(key)] unless default_for(key).nil? }.to_h
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def derived_thunk(key)
|
|
96
|
+
meta = @component_class.derived_params[key]
|
|
97
|
+
Weft::Params::Thunk.new(meta[:block]) if meta
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The wire door's default wins for dual keys — its meta always carries
|
|
101
|
+
# one, and the wire door sits above the hand-off's fallback in the stack.
|
|
102
|
+
def default_for(key)
|
|
103
|
+
wire_meta = @component_class.params[key]
|
|
104
|
+
return wire_meta[:default] if wire_meta
|
|
105
|
+
|
|
106
|
+
@component_class.received_params[key]&.[](:default)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Where the derivation in force for each key was declared — a map of
|
|
110
|
+
# declarations, not of values. It tracks what *would* run, so it
|
|
111
|
+
# survives the value being forced, and survives another source winning
|
|
112
|
+
# the key outright: a derivation that lost is still a derivation
|
|
113
|
+
# someone downstream can collide with. The nearest upstream declaration
|
|
114
|
+
# wins, since its value is the one that would be inherited.
|
|
115
|
+
def provenance
|
|
116
|
+
declared = @upstream_provenance.dup
|
|
117
|
+
@component_class.derived_params.each do |key, meta|
|
|
118
|
+
declared[key] ||= meta[:source_location]
|
|
119
|
+
end
|
|
120
|
+
declared
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Two ways a declared derivation ends up dead, each said once per
|
|
124
|
+
# (class, key). Neither is an error — both are shapes a component can
|
|
125
|
+
# legitimately want — but both mean a block someone wrote never runs,
|
|
126
|
+
# which is better heard than discovered.
|
|
127
|
+
def report_shadowed_derivations(data)
|
|
128
|
+
@component_class.derived_params.each do |key, meta|
|
|
129
|
+
next unless @received[key].nil? && @wire[key].nil?
|
|
130
|
+
|
|
131
|
+
warn_upstream_derivation(key, meta) unless @inherited[key].nil?
|
|
132
|
+
warn_overlaid_derivation(key, meta) unless @overlays[key].nil?
|
|
133
|
+
end
|
|
134
|
+
data
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# An inherited value won and was itself derived by a *different* block.
|
|
138
|
+
# A shared proc (one derivation mixed into many classes) is agreement,
|
|
139
|
+
# not divergence; values inherited through other doors carry no
|
|
140
|
+
# derivation provenance and stay silent.
|
|
141
|
+
def warn_upstream_derivation(key, meta)
|
|
142
|
+
upstream = @upstream_provenance[key]
|
|
143
|
+
return if upstream.nil? || upstream == meta[:source_location]
|
|
144
|
+
return unless warn_once?(:upstream, key)
|
|
145
|
+
|
|
146
|
+
Weft.logger.warn(
|
|
147
|
+
"#{@component_class.name}: inherited #{key.inspect} (derived at #{upstream.join(':')}) " \
|
|
148
|
+
"shadows this class's own derivation (#{meta[:source_location].join(':')}) — the " \
|
|
149
|
+
"ancestor's value wins. Use distinct keys or share one derivation if that isn't intended."
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A verb block earlier in this request returned the key. An overlay
|
|
154
|
+
# speaks as the wire, so it outranks the derivation entirely — which is
|
|
155
|
+
# exactly how a callable hands a record it already loaded to the render
|
|
156
|
+
# below it, and also how a derivation quietly stops running.
|
|
157
|
+
def warn_overlaid_derivation(key, meta)
|
|
158
|
+
return unless warn_once?(:overlaid, key)
|
|
159
|
+
|
|
160
|
+
Weft.logger.warn(
|
|
161
|
+
"#{@component_class.name}: #{key.inspect} arrived from a verb block in this request and " \
|
|
162
|
+
"outranks this class's own derivation (#{meta[:source_location].join(':')}), which will " \
|
|
163
|
+
"not run. Return a different key, or drop the derivation if the block is its only source."
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def warn_once?(kind, key) = self.class.warned.add?([kind, @component_class, key])
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/weft/params.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weft/dsl/sandbox"
|
|
4
|
+
require "weft/error"
|
|
5
|
+
|
|
6
|
+
module Weft
|
|
7
|
+
# Value object representing a component's resolved input bag.
|
|
8
|
+
# Provides method-style access with a clear collision-resolution rule:
|
|
9
|
+
# declared param names win, then the underlying Hash API is available
|
|
10
|
+
# for any name not declared as a param.
|
|
11
|
+
#
|
|
12
|
+
# Entries may be lazy: a `derives` declaration registers a Thunk that runs
|
|
13
|
+
# (at most once per bag) when its key is first read, and never runs if the
|
|
14
|
+
# key goes unread. `to_h` and delegated Hash-API calls materialize every
|
|
15
|
+
# remaining thunk first — the eager escape hatch.
|
|
16
|
+
#
|
|
17
|
+
# Action callables receive a ready-made instance (the sole argument to a
|
|
18
|
+
# +performs+/+transfers+ block); you don't construct these yourself:
|
|
19
|
+
#
|
|
20
|
+
# params.status # => "shipped" (declared param)
|
|
21
|
+
# params.count # => 42 (declared param — wins over Hash#count)
|
|
22
|
+
# params[:status] # => "shipped" (explicit hash access)
|
|
23
|
+
# params.select { ... } # delegates to the underlying hash (materializes)
|
|
24
|
+
# params.to_h # => the underlying hash (explicit escape hatch; materializes)
|
|
25
|
+
class Params
|
|
26
|
+
# @api private
|
|
27
|
+
# A registered-not-yet-run derivation. Immutable, so branch copies may
|
|
28
|
+
# share it: forcing replaces the entry in the forcing bag only, which is
|
|
29
|
+
# what gives copy-on-branch memoization its semantics.
|
|
30
|
+
class Thunk
|
|
31
|
+
attr_reader :block
|
|
32
|
+
|
|
33
|
+
def initialize(block)
|
|
34
|
+
@block = block
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @api private
|
|
39
|
+
# Constructed internally (components self-resolve via the source stack;
|
|
40
|
+
# the Router wraps bags for action callables and recovery blocks).
|
|
41
|
+
# +provenance+ maps derives-born keys to their block's source_location —
|
|
42
|
+
# retained through forcing so divergence stays detectable.
|
|
43
|
+
# +defaults+ are the declaring class's own fallbacks, consulted when a
|
|
44
|
+
# read finds nothing. They are never stored as values, so they never ride
|
|
45
|
+
# a branch: a default belongs to whoever declared it, and a component
|
|
46
|
+
# deeper in the tree — or downstream of a hand-off — falls back to its
|
|
47
|
+
# own, not to the one above it.
|
|
48
|
+
def initialize(data, provenance = {}, defaults: {})
|
|
49
|
+
@data = data
|
|
50
|
+
@provenance = provenance
|
|
51
|
+
@defaults = defaults
|
|
52
|
+
@forcing = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
attr_reader :provenance
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
# A branchable snapshot for the inheritance axis: forced values and
|
|
60
|
+
# still-lazy thunks both ride (thunks are shared objects — forcing
|
|
61
|
+
# happens per bag, which is what makes the memo copy-on-branch); nils
|
|
62
|
+
# don't ride (nil means "nobody had it" and must not shadow a
|
|
63
|
+
# descendant's own defaults).
|
|
64
|
+
def branch_data
|
|
65
|
+
@data.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @api private
|
|
69
|
+
# A same-bag copy with +values+ overlaid at their keys. Unlike
|
|
70
|
+
# to_h-then-merge, nothing materializes: untouched thunks stay lazy, nil
|
|
71
|
+
# entries stay resolved-absent, provenance rides. The plain-context
|
|
72
|
+
# hand-off fallback lands received values through this.
|
|
73
|
+
def overlay(values)
|
|
74
|
+
self.class.new(@data.merge(values), @provenance, defaults: @defaults)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# nil means no source had this key — so the read falls to the declared
|
|
78
|
+
# fallback, exactly as it falls past a nil at any other level of the stack.
|
|
79
|
+
def [](key)
|
|
80
|
+
value = @data[key]
|
|
81
|
+
value = force!(key, value) if value.is_a?(Thunk)
|
|
82
|
+
value.nil? ? @defaults[key] : value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def key?(key)
|
|
86
|
+
@data.key?(key) || @defaults.key?(key)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_h = materialized
|
|
90
|
+
|
|
91
|
+
def respond_to_missing?(name, include_private = false)
|
|
92
|
+
key?(name) || @data.respond_to?(name, include_private) || super
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
96
|
+
if key?(name) && args.empty? && kwargs.empty? && !block
|
|
97
|
+
self[name]
|
|
98
|
+
elsif @data.respond_to?(name)
|
|
99
|
+
materialized.public_send(name, *args, **kwargs, &block)
|
|
100
|
+
else
|
|
101
|
+
super
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The bag as a plain hash: every thunk run, every unsupplied key standing
|
|
108
|
+
# at its declared fallback.
|
|
109
|
+
def materialized
|
|
110
|
+
materialize!
|
|
111
|
+
@defaults.merge(@data) { |_key, fallback, value| value.nil? ? fallback : value }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Run a thunk with the bag as its argument (derivations chain by reading
|
|
115
|
+
# sibling keys) and memoize the result in place. A failed derivation is
|
|
116
|
+
# not memoized — like RSpec's let, it reruns if read again. The in-flight
|
|
117
|
+
# list turns circular derivations into a clear error instead of a stack
|
|
118
|
+
# overflow.
|
|
119
|
+
def force!(key, thunk)
|
|
120
|
+
if @forcing.include?(key)
|
|
121
|
+
raise Weft::InvalidUsage,
|
|
122
|
+
"circular derivation: #{(@forcing + [key]).join(' -> ')} " \
|
|
123
|
+
"(a derives block may not read its own key)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
@forcing << key
|
|
127
|
+
begin
|
|
128
|
+
@data[key] = Weft::DSL::Sandbox.run(self, &thunk.block)
|
|
129
|
+
ensure
|
|
130
|
+
@forcing.pop
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def materialize!
|
|
135
|
+
@data.each_key { |key| self[key] }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
data/lib/weft/presets.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "weft/error"
|
|
4
|
+
|
|
5
|
+
module Weft
|
|
6
|
+
# Registry for named interaction presets. Each preset is a bundle of
|
|
7
|
+
# trigger/swap/target defaults that Context expands via loads:.
|
|
8
|
+
#
|
|
9
|
+
# Shipped presets are registered at the bottom of this file. Users will be
|
|
10
|
+
# able to register custom presets in v1.x via the same API.
|
|
11
|
+
module Presets
|
|
12
|
+
# Weft's own element-kwarg vocabulary (keep in sync with
|
|
13
|
+
# Context::Expansion's grammar; :prompt reserved ahead of its arrival).
|
|
14
|
+
# A preset by one of these names would shadow the expansion grammar.
|
|
15
|
+
RESERVED_KWARGS = %i[action navigate loads trigger push_url swap target with confirm prompt].freeze
|
|
16
|
+
|
|
17
|
+
# HTML attribute names a preset would shadow: a registered preset claims
|
|
18
|
+
# its kwarg whenever the value is a Class or String, so `title: "..."`
|
|
19
|
+
# would stop rendering the HTML attribute. Global attributes plus the
|
|
20
|
+
# commonly-passed per-element ones.
|
|
21
|
+
HTML_ATTRIBUTE_NAMES = %i[
|
|
22
|
+
accesskey autocapitalize autofocus class contenteditable dir draggable enterkeyhint
|
|
23
|
+
hidden id inert inputmode lang nonce popover role slot spellcheck style tabindex
|
|
24
|
+
title translate
|
|
25
|
+
href src alt name value type placeholder disabled readonly required checked selected
|
|
26
|
+
multiple method rel download media width height
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Register a named interaction preset.
|
|
31
|
+
#
|
|
32
|
+
# Weft::Presets.register :tooltip, trigger: :hover, swap: :fill
|
|
33
|
+
def register(name, **defaults)
|
|
34
|
+
validate_name!(name)
|
|
35
|
+
registry[name] = defaults
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Look up a registered preset by name. Returns the defaults hash or nil.
|
|
39
|
+
def lookup(name)
|
|
40
|
+
registry[name]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# All registered preset names.
|
|
44
|
+
def registered
|
|
45
|
+
registry.keys
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def validate_name!(name)
|
|
51
|
+
if RESERVED_KWARGS.include?(name)
|
|
52
|
+
raise Weft::InvalidDefinition,
|
|
53
|
+
"cannot register preset #{name.inspect} — it is Weft's own element-kwarg vocabulary"
|
|
54
|
+
end
|
|
55
|
+
return unless HTML_ATTRIBUTE_NAMES.include?(name)
|
|
56
|
+
|
|
57
|
+
Weft.logger.warn "preset #{name.inspect} shadows the HTML attribute of the same name — " \
|
|
58
|
+
"elements passing a Class or String value for #{name}: will expand as this " \
|
|
59
|
+
"preset instead of rendering the attribute"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def registry
|
|
63
|
+
@registry ||= {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Shipped interaction presets — each bundles the htmx wiring for a common
|
|
70
|
+
# hypermedia interaction pattern. Adding one is a single line; Context dispatch
|
|
71
|
+
# + loads: expansion handles the rest.
|
|
72
|
+
|
|
73
|
+
Weft::Presets.register :tooltip, trigger: :hover, swap: :fill
|
|
74
|
+
# click_once, not click: the inserted detail lands after the trigger element,
|
|
75
|
+
# so a repeat click would insert a second copy.
|
|
76
|
+
Weft::Presets.register :inline_expand, trigger: :click_once, swap: :after
|
|
77
|
+
Weft::Presets.register :lazy, trigger: :visible, swap: :fill, target: :self
|
|
78
|
+
Weft::Presets.register :modal, trigger: :click, swap: :fill
|
|
79
|
+
Weft::Presets.register :load_more, trigger: :click, swap: :replace, target: :self
|
|
80
|
+
Weft::Presets.register :infinite_scroll, trigger: :visible, swap: :after
|
|
81
|
+
Weft::Presets.register :live_search, trigger: :input, swap: :fill
|
|
82
|
+
Weft::Presets.register :tabs, trigger: :click, swap: :fill
|
|
83
|
+
|
|
84
|
+
# Retry is the odd one out: its value is a URL (the failing component's own GET
|
|
85
|
+
# URL, injected as the :retry_url recovery param), not a target Class. It ships a
|
|
86
|
+
# concrete hx-target so error components never hand-write htmx — outerHTML-swapping
|
|
87
|
+
# the enclosing .weft-error box replaces the whole error display with the fresh
|
|
88
|
+
# component. Callers override target: for a differently-classed container.
|
|
89
|
+
Weft::Presets.register :retry, trigger: :click, swap: :outer_html, target: "closest .weft-error"
|
|
90
|
+
|
|
91
|
+
# Reopen_stream is retry's sibling for closed SSE streams (same URL value,
|
|
92
|
+
# usually :retry_url). Targeting the enclosing sse-swap wrapper — the element
|
|
93
|
+
# left behind after the Router closes a stream — means the fresh render
|
|
94
|
+
# replaces it wholesale, and its new sse-connect attribute makes htmx open a
|
|
95
|
+
# fresh EventSource: live updates resume with a full attempts budget.
|
|
96
|
+
Weft::Presets.register :reopen_stream, trigger: :click, swap: :outer_html, target: "closest [sse-swap]"
|
data/lib/weft/redirect.rb
CHANGED
|
@@ -14,30 +14,30 @@ module Weft
|
|
|
14
14
|
# Convenience wrapper:
|
|
15
15
|
# Weft.redirect(OrderDetailPage, order_id: order.id)
|
|
16
16
|
class Redirect
|
|
17
|
-
attr_reader :target, :
|
|
17
|
+
attr_reader :target, :params
|
|
18
18
|
|
|
19
19
|
# @api private
|
|
20
20
|
# Use {Redirect.to} (or {Weft.redirect}) — +new+ is private.
|
|
21
|
-
def initialize(target, **
|
|
21
|
+
def initialize(target, **params)
|
|
22
22
|
@target = target
|
|
23
|
-
@
|
|
23
|
+
@params = params
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Resolve the redirect URL.
|
|
27
|
-
# Page targets: interpolate
|
|
27
|
+
# Page targets: interpolate params into page_path pattern.
|
|
28
28
|
# String targets: use as-is.
|
|
29
29
|
def url
|
|
30
30
|
case @target
|
|
31
31
|
when String
|
|
32
32
|
@target
|
|
33
33
|
else
|
|
34
|
-
@target.resolve_page_path(@
|
|
34
|
+
@target.resolve_page_path(@params)
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
# Primary constructor.
|
|
39
|
-
def self.to(target, **
|
|
40
|
-
new(target, **
|
|
39
|
+
def self.to(target, **params)
|
|
40
|
+
new(target, **params)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
private_class_method :new
|
|
@@ -29,30 +29,16 @@ module Weft
|
|
|
29
29
|
@routable_explicit = false
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Same switch as {abstract!}, named for the other reason to flip it:
|
|
33
|
+
# "I rely on state I can only receive, not reconstruct — my parent must
|
|
34
|
+
# hand it to me every time, so serving me standalone makes no sense."
|
|
35
|
+
alias dependent! abstract!
|
|
36
|
+
|
|
32
37
|
# Force this class to be routable, even if its declared state would
|
|
33
38
|
# otherwise make it non-routable. Does not percolate to subclasses.
|
|
34
39
|
def routable!
|
|
35
40
|
@routable_explicit = true
|
|
36
41
|
end
|
|
37
|
-
|
|
38
|
-
# Whether this class object has been superseded — its fully-qualified name
|
|
39
|
-
# now resolves to a *different* class. This is the code-reload case: a
|
|
40
|
-
# reloader (e.g. Zeitwerk in development) redefines the constant, binding a
|
|
41
|
-
# new class object to the name while the old one lingers in the registry.
|
|
42
|
-
# The registry drops superseded classes so only the current definition
|
|
43
|
-
# routes (otherwise the two would look like a route collision).
|
|
44
|
-
#
|
|
45
|
-
# Classes whose name does not resolve to a constant — anonymous classes,
|
|
46
|
-
# or test doubles that stub +.name+ — are never stale. Override for
|
|
47
|
-
# bespoke liveness semantics.
|
|
48
|
-
#
|
|
49
|
-
# @note Uses ActiveSupport's +safe_constantize+, which walks the namespace.
|
|
50
|
-
# The registry calls this only at route-resolution time (memoized), so
|
|
51
|
-
# the cost is paid once per registry generation, not per request.
|
|
52
|
-
def stale?
|
|
53
|
-
current = name&.safe_constantize
|
|
54
|
-
!current.nil? && !equal?(current)
|
|
55
|
-
end
|
|
56
42
|
end
|
|
57
43
|
end
|
|
58
44
|
end
|
data/lib/weft/registry.rb
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Weft
|
|
4
|
+
class << self
|
|
5
|
+
# The process-wide registry. Defined here rather than in the gem root
|
|
6
|
+
# because registration happens at class-definition time (the `inherited`
|
|
7
|
+
# hooks on Component and Page call this), so any file that defines a
|
|
8
|
+
# subclass needs the accessor resolvable at load.
|
|
9
|
+
def registry
|
|
10
|
+
@registry ||= Registry.new
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
4
14
|
# Stores registered Weft::Component and Weft::Page classes for the Router
|
|
5
15
|
# to consume at request time.
|
|
6
16
|
#
|
|
@@ -45,10 +55,8 @@ module Weft
|
|
|
45
55
|
@routes_validated = false
|
|
46
56
|
end
|
|
47
57
|
|
|
48
|
-
# Empty the registry.
|
|
49
|
-
#
|
|
50
|
-
# automatically (superseded classes are pruned — see stale-class handling
|
|
51
|
-
# in validate_routes!).
|
|
58
|
+
# Empty the registry. The evict-everything reset for hand-rolled reload
|
|
59
|
+
# integrations and for tests; per-class eviction is #evict.
|
|
52
60
|
def clear
|
|
53
61
|
@components.clear
|
|
54
62
|
@pages.clear
|
|
@@ -57,6 +65,21 @@ module Weft
|
|
|
57
65
|
@routes_validated = false
|
|
58
66
|
end
|
|
59
67
|
|
|
68
|
+
# Remove one class from the registry, invalidating route lookup so a fresh
|
|
69
|
+
# same-path registration serves cleanly. This is the reload-eviction
|
|
70
|
+
# primitive: Weft.configure_autoloading wires it to Zeitwerk's on_unload
|
|
71
|
+
# callback, and hand-rolled reloaders should call it (or #clear) as classes
|
|
72
|
+
# unload. Safe to call with any object — non-registered classes (models,
|
|
73
|
+
# service objects) fall through untouched. Returns true if evicted.
|
|
74
|
+
def evict(klass) # rubocop:disable Naming/PredicateMethod -- a command with a did-evict status return
|
|
75
|
+
return false unless @components.delete?(klass) || @pages.delete?(klass)
|
|
76
|
+
|
|
77
|
+
@path_index = nil
|
|
78
|
+
@sse_present = nil
|
|
79
|
+
@routes_validated = false
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
60
83
|
def pages
|
|
61
84
|
@pages.to_a
|
|
62
85
|
end
|
|
@@ -116,16 +139,6 @@ module Weft
|
|
|
116
139
|
page_class.page_path || page_class.send(:default_page_path)
|
|
117
140
|
end
|
|
118
141
|
|
|
119
|
-
# Drop classes whose constant has been redefined out from under them (the
|
|
120
|
-
# code-reload case — see Weft::Registry::Eligibility#stale?). Without this, a
|
|
121
|
-
# reloaded class and its stale predecessor resolve to the same path and look
|
|
122
|
-
# like a route collision. Runs once per registry generation via
|
|
123
|
-
# validate_routes!; @path_index is rebuilt only if a component was removed.
|
|
124
|
-
def prune_stale!
|
|
125
|
-
@path_index = nil if @components.reject!(&:stale?)
|
|
126
|
-
@pages.reject!(&:stale?)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
142
|
# Build the effective-route table across every routable component (its base
|
|
130
143
|
# path plus its reserved stream-suffix tail) and routable page (its resolved
|
|
131
144
|
# pattern), and raise Weft::InvalidDefinition on any duplicate — component vs
|
|
@@ -136,9 +149,9 @@ module Weft
|
|
|
136
149
|
def validate_routes!
|
|
137
150
|
return if @routes_validated
|
|
138
151
|
|
|
139
|
-
prune_stale!
|
|
140
152
|
seen = {}
|
|
141
153
|
routable_components.each do |klass|
|
|
154
|
+
warn_dependent_receives!(klass)
|
|
142
155
|
base = klass.resolved_component_path
|
|
143
156
|
add_route!(seen, base, klass, :component)
|
|
144
157
|
add_route!(seen, "#{base}/#{Weft.configuration.stream_suffix}", klass, :stream)
|
|
@@ -147,6 +160,24 @@ module Weft
|
|
|
147
160
|
@routes_validated = true
|
|
148
161
|
end
|
|
149
162
|
|
|
163
|
+
# A routable component with a required hand-off it cannot reconstruct
|
|
164
|
+
# standalone will raise on every refresh (nothing hands the value over).
|
|
165
|
+
# Defaulted hand-offs are exempt (declaring a default explicitly opts
|
|
166
|
+
# into standalone renders falling back to it), as are dual keys — a wire
|
|
167
|
+
# param or a derives supplies the standalone value. Runs once per
|
|
168
|
+
# registry generation, alongside route validation.
|
|
169
|
+
def warn_dependent_receives!(klass)
|
|
170
|
+
required = klass.received_params.reject { |_, meta| meta.key?(:default) }.keys
|
|
171
|
+
undualed = required - klass.params.keys - klass.derived_params.keys
|
|
172
|
+
return if undualed.empty?
|
|
173
|
+
|
|
174
|
+
Weft.logger.warn(
|
|
175
|
+
"#{klass.name} is routable but depends on hand-offs it cannot reconstruct standalone " \
|
|
176
|
+
"(#{undualed.map(&:inspect).join(', ')}) — a refresh will raise without them. " \
|
|
177
|
+
"Mark the class dependent!, or declare a derives or wire param dual for the key."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
150
181
|
def add_route!(seen, path, klass, kind)
|
|
151
182
|
validate_route_shape!(path, klass, kind)
|
|
152
183
|
if (existing = seen[path])
|
|
@@ -167,10 +198,19 @@ module Weft
|
|
|
167
198
|
"a route must be a non-empty string beginning with \"/\"."
|
|
168
199
|
end
|
|
169
200
|
|
|
201
|
+
# Two same-named class objects at one route is the signature of code
|
|
202
|
+
# reloading without eviction — say so, rather than suggesting a rename.
|
|
170
203
|
def collision_message(path, existing, incoming)
|
|
171
|
-
|
|
172
|
-
"#{
|
|
173
|
-
|
|
204
|
+
if existing[0].name == incoming[0].name
|
|
205
|
+
"Route collision on #{path.inspect}: two class objects named #{existing[0].name} " \
|
|
206
|
+
"are registered — usually a code reloading setup that never evicts. Evict classes " \
|
|
207
|
+
"as they unload (Weft.registry.evict, e.g. from a Zeitwerk on_unload callback), " \
|
|
208
|
+
"or reset with Weft.registry.clear before each reload."
|
|
209
|
+
else
|
|
210
|
+
"Route collision on #{path.inspect}: #{route_label(*existing)} and " \
|
|
211
|
+
"#{route_label(*incoming)} resolve to the same route. Rename one class, " \
|
|
212
|
+
"set an explicit component_path/page_path, or mark one abstract! if it should not route."
|
|
213
|
+
end
|
|
174
214
|
end
|
|
175
215
|
|
|
176
216
|
def route_label(klass, kind)
|