vident 1.0.1 → 1.0.2
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 +11 -0
- data/README.md +4 -1
- data/lib/vident/component_attribute_resolver.rb +27 -8
- data/lib/vident/component_class_lists.rb +3 -0
- data/lib/vident/stimulus_builder.rb +28 -11
- data/lib/vident/stimulus_helper.rb +4 -4
- data/lib/vident/version.rb +1 -1
- data/lib/vident2/caching.rb +93 -0
- data/lib/vident2/component.rb +538 -0
- data/lib/vident2/engine.rb +18 -0
- data/lib/vident2/error.rb +30 -0
- data/lib/vident2/internals/action_builder.rb +101 -0
- data/lib/vident2/internals/attribute_writer.rb +22 -0
- data/lib/vident2/internals/class_list_builder.rb +79 -0
- data/lib/vident2/internals/declaration.rb +17 -0
- data/lib/vident2/internals/declarations.rb +76 -0
- data/lib/vident2/internals/draft.rb +60 -0
- data/lib/vident2/internals/dsl.rb +198 -0
- data/lib/vident2/internals/plan.rb +12 -0
- data/lib/vident2/internals/registry.rb +41 -0
- data/lib/vident2/internals/resolver.rb +306 -0
- data/lib/vident2/internals/target_builder.rb +29 -0
- data/lib/vident2/phlex/html.rb +84 -0
- data/lib/vident2/phlex.rb +9 -0
- data/lib/vident2/stimulus/action.rb +140 -0
- data/lib/vident2/stimulus/class_map.rb +69 -0
- data/lib/vident2/stimulus/collection.rb +42 -0
- data/lib/vident2/stimulus/controller.rb +59 -0
- data/lib/vident2/stimulus/naming.rb +26 -0
- data/lib/vident2/stimulus/null.rb +16 -0
- data/lib/vident2/stimulus/outlet.rb +113 -0
- data/lib/vident2/stimulus/param.rb +62 -0
- data/lib/vident2/stimulus/target.rb +57 -0
- data/lib/vident2/stimulus/value.rb +77 -0
- data/lib/vident2/tailwind.rb +19 -0
- data/lib/vident2/version.rb +5 -0
- data/lib/vident2/view_component/base.rb +124 -0
- data/lib/vident2/view_component.rb +9 -0
- data/lib/vident2.rb +50 -0
- data/skills/vident/SKILL.md +11 -2
- data/skills/vident/api-reference.md +518 -0
- data/skills/vident/examples.md +492 -0
- metadata +35 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
require_relative "draft"
|
|
5
|
+
|
|
6
|
+
module Vident2
|
|
7
|
+
module Internals
|
|
8
|
+
# @api private
|
|
9
|
+
# Resolves Declarations + instance state into a Draft of typed
|
|
10
|
+
# Stimulus::* values. The implied controller seeds first (unless
|
|
11
|
+
# `no_stimulus_controller`); prop and root_element_attributes paths
|
|
12
|
+
# both APPEND.
|
|
13
|
+
#
|
|
14
|
+
# Two entry points:
|
|
15
|
+
# `call` — pure; returns a new Draft. Use `:static` or `:all`.
|
|
16
|
+
# `resolve_procs_into` — mutates an existing Draft. Called at render
|
|
17
|
+
# time so DSL procs can reach `helpers` /
|
|
18
|
+
# `view_context` (not wired at `after_initialize`).
|
|
19
|
+
#
|
|
20
|
+
# Phases: `:static` skips anything with a `when_proc` or top-level Proc
|
|
21
|
+
# in args; `:procs` processes only those; `:all` does everything.
|
|
22
|
+
#
|
|
23
|
+
# Procs nested inside a Hash descriptor (`action(method: -> { ... })`)
|
|
24
|
+
# escape the gate — unsupported shape; use the fluent builder or a
|
|
25
|
+
# top-level Proc.
|
|
26
|
+
module Resolver
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def call(declarations, instance, phase: :all)
|
|
30
|
+
raise ArgumentError, "use resolve_procs_into for phase: :procs" if phase == :procs
|
|
31
|
+
|
|
32
|
+
draft = Draft.new
|
|
33
|
+
implied = build_implied_controller(instance)
|
|
34
|
+
|
|
35
|
+
seed_implied_controller(draft, instance)
|
|
36
|
+
resolve_declarations(draft, declarations, instance, implied, phase: phase)
|
|
37
|
+
absorb_stimulus_props(draft, instance, implied, phase: phase)
|
|
38
|
+
absorb_root_element_attributes(draft, instance, implied, phase: phase)
|
|
39
|
+
|
|
40
|
+
draft
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Caller owns idempotence (Component uses `@__vident2_procs_resolved`).
|
|
44
|
+
def resolve_procs_into(draft, declarations, instance)
|
|
45
|
+
implied = build_implied_controller(instance)
|
|
46
|
+
resolve_declarations(draft, declarations, instance, implied, phase: :procs)
|
|
47
|
+
absorb_stimulus_props(draft, instance, implied, phase: :procs)
|
|
48
|
+
absorb_root_element_attributes(draft, instance, implied, phase: :procs)
|
|
49
|
+
draft
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_implied_controller(instance)
|
|
53
|
+
path = instance.class.stimulus_identifier_path
|
|
54
|
+
name = instance.class.stimulus_identifier
|
|
55
|
+
::Vident2::Stimulus::Controller.new(path: path, name: name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def seed_implied_controller(draft, instance)
|
|
59
|
+
return unless instance.class.stimulus_controller?
|
|
60
|
+
draft.add_controllers(build_implied_controller(instance))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_declarations(draft, declarations, instance, implied, phase:)
|
|
64
|
+
resolve_positional(draft, :controllers, declarations.controllers, instance, phase: phase) do |args, meta, _inst|
|
|
65
|
+
::Vident2::Stimulus::Controller.parse(*args, implied: implied, **meta_for_controller(meta))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
resolve_positional(draft, :actions, declarations.actions, instance, phase: phase) do |args, _meta, _inst|
|
|
69
|
+
parse_single(::Vident2::Stimulus::Action, args, implied: implied, component_id: instance_id(instance))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
resolve_positional(draft, :targets, declarations.targets, instance, phase: phase) do |args, _meta, _inst|
|
|
73
|
+
parse_single(::Vident2::Stimulus::Target, args, implied: implied, component_id: instance_id(instance))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
resolve_keyed(draft, :outlets, declarations.outlets, instance, phase: phase) do |key, args, _meta|
|
|
77
|
+
parsed_args = [key_for_parse(key), *args]
|
|
78
|
+
parse_single(::Vident2::Stimulus::Outlet, parsed_args, implied: implied, component_id: instance_id(instance))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
resolve_keyed_values(draft, declarations, instance, implied, phase: phase)
|
|
82
|
+
resolve_keyed_scalars(draft, :params, declarations.params, instance, implied, ::Vident2::Stimulus::Param, phase: phase)
|
|
83
|
+
resolve_keyed_scalars(draft, :class_maps, declarations.class_maps, instance, implied, ::Vident2::Stimulus::ClassMap, phase: phase)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_positional(draft, kind, entries, instance, phase:)
|
|
87
|
+
entries.each do |decl|
|
|
88
|
+
next unless phase_matches?(decl, phase)
|
|
89
|
+
next if gated_out?(decl.when_proc, instance)
|
|
90
|
+
args = resolve_args(decl.args, instance)
|
|
91
|
+
next if args.nil?
|
|
92
|
+
args = splat_single_array(args)
|
|
93
|
+
parsed = yield(args, decl.meta, instance)
|
|
94
|
+
draft.public_send(:"add_#{kind}", parsed) if parsed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# If `args` is a single Array element, unwrap it — positional kinds
|
|
99
|
+
# treat Array values as the singular parser's arg tuple.
|
|
100
|
+
def splat_single_array(args)
|
|
101
|
+
(args.size == 1 && args[0].is_a?(Array)) ? args[0] : args
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def resolve_keyed(draft, kind, entries, instance, phase:)
|
|
105
|
+
entries.each do |(key, decl)|
|
|
106
|
+
next unless phase_matches?(decl, phase)
|
|
107
|
+
next if gated_out?(decl.when_proc, instance)
|
|
108
|
+
args = resolve_args(decl.args, instance)
|
|
109
|
+
next if args.nil?
|
|
110
|
+
parsed = yield(key, args, decl.meta)
|
|
111
|
+
draft.public_send(:"add_#{kind}", parsed) if parsed
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_keyed_values(draft, declarations, instance, implied, phase:)
|
|
116
|
+
declarations.values.each do |(key, decl)|
|
|
117
|
+
next unless phase_matches?(decl, phase)
|
|
118
|
+
next if gated_out?(decl.when_proc, instance)
|
|
119
|
+
|
|
120
|
+
if decl.meta[:from_prop]
|
|
121
|
+
raw = read_prop(instance, key)
|
|
122
|
+
next if raw.nil?
|
|
123
|
+
draft.add_values(::Vident2::Stimulus::Value.parse(key, raw, implied: implied))
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
raw = resolve_value_meta(decl, instance)
|
|
128
|
+
next if raw.nil?
|
|
129
|
+
draft.add_values(::Vident2::Stimulus::Value.parse(key, raw, implied: implied))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# values_from_props is a plain Symbol list (no Declarations, so
|
|
133
|
+
# phase_matches? doesn't apply). Ivar reads only; run once.
|
|
134
|
+
return if phase == :procs
|
|
135
|
+
declarations.values_from_props.each do |name|
|
|
136
|
+
raw = read_prop(instance, name)
|
|
137
|
+
next if raw.nil?
|
|
138
|
+
draft.add_values(::Vident2::Stimulus::Value.parse(name, raw, implied: implied))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_keyed_scalars(draft, kind, entries, instance, implied, value_class, phase:)
|
|
143
|
+
entries.each do |(key, decl)|
|
|
144
|
+
next unless phase_matches?(decl, phase)
|
|
145
|
+
next if gated_out?(decl.when_proc, instance)
|
|
146
|
+
raw = resolve_value_meta(decl, instance)
|
|
147
|
+
next if raw.nil?
|
|
148
|
+
draft.public_send(:"add_#{kind}", value_class.parse(key, raw, implied: implied))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Declaration-level phase gate. Nested Procs inside Hash args
|
|
153
|
+
# aren't inspected — see module docstring.
|
|
154
|
+
def phase_matches?(decl, phase)
|
|
155
|
+
return true if phase == :all
|
|
156
|
+
has_proc = decl.when_proc || decl.args.any? { |a| a.is_a?(Proc) }
|
|
157
|
+
(phase == :procs) ? has_proc : !has_proc
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def absorb_stimulus_props(draft, instance, implied, phase:)
|
|
161
|
+
absorb_input(draft, :controllers, instance_ivar(instance, :@stimulus_controllers), instance, implied, phase: phase)
|
|
162
|
+
absorb_input(draft, :actions, instance_ivar(instance, :@stimulus_actions), instance, implied, phase: phase)
|
|
163
|
+
absorb_input(draft, :targets, instance_ivar(instance, :@stimulus_targets), instance, implied, phase: phase)
|
|
164
|
+
absorb_input(draft, :outlets, instance_ivar(instance, :@stimulus_outlets), instance, implied, phase: phase)
|
|
165
|
+
absorb_input(draft, :values, instance_ivar(instance, :@stimulus_values), instance, implied, phase: phase)
|
|
166
|
+
absorb_input(draft, :params, instance_ivar(instance, :@stimulus_params), instance, implied, phase: phase)
|
|
167
|
+
absorb_input(draft, :class_maps, instance_ivar(instance, :@stimulus_classes), instance, implied, phase: phase)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def absorb_root_element_attributes(draft, instance, implied, phase:)
|
|
171
|
+
return unless instance.respond_to?(:resolved_root_element_attributes, true)
|
|
172
|
+
attrs = instance.send(:resolved_root_element_attributes)
|
|
173
|
+
return unless attrs.is_a?(Hash) && !attrs.empty?
|
|
174
|
+
|
|
175
|
+
absorb_input(draft, :controllers, attrs[:stimulus_controllers], instance, implied, phase: phase)
|
|
176
|
+
absorb_input(draft, :actions, attrs[:stimulus_actions], instance, implied, phase: phase)
|
|
177
|
+
absorb_input(draft, :targets, attrs[:stimulus_targets], instance, implied, phase: phase)
|
|
178
|
+
absorb_input(draft, :outlets, attrs[:stimulus_outlets], instance, implied, phase: phase)
|
|
179
|
+
absorb_input(draft, :values, attrs[:stimulus_values], instance, implied, phase: phase)
|
|
180
|
+
absorb_input(draft, :params, attrs[:stimulus_params], instance, implied, phase: phase)
|
|
181
|
+
absorb_input(draft, :class_maps, attrs[:stimulus_classes], instance, implied, phase: phase)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Fold a prop / root_element_attributes value into the Draft.
|
|
185
|
+
# Each Hash value / Array element may be a Proc; phase-gated.
|
|
186
|
+
def absorb_input(draft, kind, input, instance, implied, phase:)
|
|
187
|
+
return if input.nil?
|
|
188
|
+
|
|
189
|
+
kind_meta = Registry.fetch(kind)
|
|
190
|
+
case input
|
|
191
|
+
when Hash
|
|
192
|
+
input.each do |key, raw|
|
|
193
|
+
is_proc = raw.is_a?(Proc)
|
|
194
|
+
next unless phase_allows?(is_proc, phase)
|
|
195
|
+
absorbed = is_proc ? instance.instance_exec(&raw) : raw
|
|
196
|
+
next if absorbed.nil?
|
|
197
|
+
if kind_meta.keyed
|
|
198
|
+
parsed = kind_meta.value_class.parse(key, absorbed, implied: implied, component_id: instance_id(instance))
|
|
199
|
+
draft.public_send(:"add_#{kind}", parsed)
|
|
200
|
+
else
|
|
201
|
+
parsed = parse_entry(kind_meta, [key, absorbed], implied: implied, component_id: instance_id(instance))
|
|
202
|
+
draft.public_send(:"add_#{kind}", parsed) if parsed
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
when Array
|
|
206
|
+
input.each do |entry|
|
|
207
|
+
is_proc = entry.is_a?(Proc)
|
|
208
|
+
next unless phase_allows?(is_proc, phase)
|
|
209
|
+
parsed = absorb_one(kind_meta, entry, instance, implied)
|
|
210
|
+
draft.public_send(:"add_#{kind}", parsed) if parsed
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
is_proc = input.is_a?(Proc)
|
|
214
|
+
return unless phase_allows?(is_proc, phase)
|
|
215
|
+
parsed = absorb_one(kind_meta, input, instance, implied)
|
|
216
|
+
draft.public_send(:"add_#{kind}", parsed) if parsed
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Element-level gate (raw boolean; parallels Declaration-level `phase_matches?`).
|
|
221
|
+
def phase_allows?(is_proc, phase)
|
|
222
|
+
case phase
|
|
223
|
+
when :all then true
|
|
224
|
+
when :static then !is_proc
|
|
225
|
+
when :procs then is_proc
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def absorb_one(kind_meta, entry, instance, implied)
|
|
230
|
+
entry = instance.instance_exec(&entry) if entry.is_a?(Proc)
|
|
231
|
+
return nil if entry.nil?
|
|
232
|
+
return entry if entry.is_a?(kind_meta.value_class)
|
|
233
|
+
|
|
234
|
+
parse_entry(kind_meta, entry, implied: implied, component_id: instance_id(instance))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def parse_entry(kind_meta, entry, implied:, component_id:)
|
|
238
|
+
case entry
|
|
239
|
+
when Hash
|
|
240
|
+
if kind_meta.keyed
|
|
241
|
+
first_key, first_val = entry.first
|
|
242
|
+
kind_meta.value_class.parse(first_key, first_val, implied: implied, component_id: component_id)
|
|
243
|
+
else
|
|
244
|
+
kind_meta.value_class.parse(entry, implied: implied, component_id: component_id)
|
|
245
|
+
end
|
|
246
|
+
when Array
|
|
247
|
+
kind_meta.value_class.parse(*entry, implied: implied, component_id: component_id)
|
|
248
|
+
else
|
|
249
|
+
kind_meta.value_class.parse(entry, implied: implied, component_id: component_id)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Evaluate proc args in the instance binding. Only nil drops —
|
|
254
|
+
# false / blank strings / empty collections reach the parser.
|
|
255
|
+
def resolve_args(args, instance)
|
|
256
|
+
resolved = args.map { |arg| arg.is_a?(Proc) ? instance.instance_exec(&arg) : arg }
|
|
257
|
+
return nil if resolved.any?(&:nil?)
|
|
258
|
+
resolved
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Values accept the raw in args (proc or literal) or meta (`static:`).
|
|
262
|
+
def resolve_value_meta(decl, instance)
|
|
263
|
+
return decl.meta[:static] if decl.meta.key?(:static)
|
|
264
|
+
return nil if decl.args.empty?
|
|
265
|
+
|
|
266
|
+
raw = decl.args.first
|
|
267
|
+
raw = instance.instance_exec(&raw) if raw.is_a?(Proc)
|
|
268
|
+
raw
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def gated_out?(when_proc, instance)
|
|
272
|
+
return false unless when_proc
|
|
273
|
+
!instance.instance_exec(&when_proc)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def parse_single(value_class, args, implied:, component_id:)
|
|
277
|
+
value_class.parse(*args, implied: implied, component_id: component_id)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def key_for_parse(key) = key
|
|
281
|
+
|
|
282
|
+
# Controller parse takes `as:` as a kwarg, not a positional.
|
|
283
|
+
def meta_for_controller(meta) = meta.slice(:as)
|
|
284
|
+
|
|
285
|
+
def instance_ivar(instance, name)
|
|
286
|
+
return nil unless instance.instance_variable_defined?(name)
|
|
287
|
+
instance.instance_variable_get(name)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Raw ivar — calling `#id` would trigger auto-generation, and
|
|
291
|
+
# outlet auto-selectors include the `#<id>` prefix only when the
|
|
292
|
+
# user explicitly set one.
|
|
293
|
+
def instance_id(instance)
|
|
294
|
+
return nil unless instance.instance_variable_defined?(:@id)
|
|
295
|
+
raw = instance.instance_variable_get(:@id)
|
|
296
|
+
raw.presence
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def read_prop(instance, name)
|
|
300
|
+
ivar = :"@#{name}"
|
|
301
|
+
return nil unless instance.instance_variable_defined?(ivar)
|
|
302
|
+
instance.instance_variable_get(ivar)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "declaration"
|
|
4
|
+
|
|
5
|
+
module Vident2
|
|
6
|
+
module Internals
|
|
7
|
+
# @api private
|
|
8
|
+
# Fluent chain returned by `target(...)` inside a `stimulus do` block.
|
|
9
|
+
# The only current chain method is `.when` (conditional inclusion);
|
|
10
|
+
# the target itself has no other DSL-facing knobs.
|
|
11
|
+
#
|
|
12
|
+
# target(:row).when { @rows.any? }
|
|
13
|
+
class TargetBuilder
|
|
14
|
+
def initialize(*args)
|
|
15
|
+
@args = args
|
|
16
|
+
@when_proc = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def when(callable = nil, &block)
|
|
20
|
+
@when_proc = block || callable
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_declaration
|
|
25
|
+
Declaration.new(args: @args.freeze, when_proc: @when_proc, meta: {}.freeze)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Vident2
|
|
6
|
+
module Phlex
|
|
7
|
+
class HTML < ::Phlex::HTML
|
|
8
|
+
include ::Vident2::Component
|
|
9
|
+
|
|
10
|
+
STANDARD_ELEMENTS = %i[
|
|
11
|
+
a abbr address article aside b bdi bdo blockquote body button caption
|
|
12
|
+
cite code colgroup data datalist dd del details dfn dialog div dl dt
|
|
13
|
+
em fieldset figcaption figure footer form g h1 h2 h3 h4 h5 h6 head
|
|
14
|
+
header hgroup html i iframe ins kbd label legend li main map mark
|
|
15
|
+
menuitem meter nav noscript object ol optgroup option output p path
|
|
16
|
+
picture pre progress q rp rt ruby s samp script section select slot
|
|
17
|
+
small span strong style sub summary sup svg table tbody td template_tag
|
|
18
|
+
textarea tfoot th thead time title tr u ul video wbr
|
|
19
|
+
].freeze
|
|
20
|
+
VOID_ELEMENTS = %i[area br embed hr img input link meta param source track col].freeze
|
|
21
|
+
VALID_TAGS = Set[*(STANDARD_ELEMENTS + VOID_ELEMENTS)].freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Capture the subclass's defining `.rb` path at class-definition
|
|
25
|
+
# time so Caching#cache_component_modified_time can read its mtime.
|
|
26
|
+
# Walks caller_locations to skip the `inherited` frame itself.
|
|
27
|
+
def inherited(subclass)
|
|
28
|
+
loc = caller_locations(1, 10).reject { |l| l.label == "inherited" }[0]
|
|
29
|
+
subclass.component_source_file_path = loc&.path
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_accessor :component_source_file_path
|
|
34
|
+
|
|
35
|
+
def cache_component_modified_time
|
|
36
|
+
path = component_source_file_path
|
|
37
|
+
raise StandardError, "No component source file exists #{path}" unless path && ::File.exist?(path)
|
|
38
|
+
::File.mtime(path).to_i.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Phlex lifecycle hook: resolve stimulus DSL procs now that
|
|
43
|
+
# `view_context` / `helpers` are wired. Procs declared in the DSL
|
|
44
|
+
# stayed unresolved at `after_initialize`; this is where they run.
|
|
45
|
+
def before_template
|
|
46
|
+
resolve_stimulus_attributes_at_render_time
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Block-capture-first so children initialising inside the block can
|
|
51
|
+
# mutate THIS instance's Draft (outlet-host pattern). After the
|
|
52
|
+
# block returns, we seal the Draft and emit the tag.
|
|
53
|
+
def root_element(**overrides, &block)
|
|
54
|
+
tag_type = root_element_tag_type
|
|
55
|
+
check_valid_html_tag!(tag_type)
|
|
56
|
+
if block
|
|
57
|
+
content = capture(self, &block).html_safe
|
|
58
|
+
options = build_root_element_attributes(overrides)
|
|
59
|
+
send(tag_type, **options) { content }
|
|
60
|
+
else
|
|
61
|
+
options = build_root_element_attributes(overrides)
|
|
62
|
+
send(tag_type, **options)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def check_valid_html_tag!(tag_name)
|
|
69
|
+
return if VALID_TAGS.include?(tag_name)
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"Unsupported HTML tag name #{tag_name}. Valid tags are: #{VALID_TAGS.to_a.join(", ")}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Phlex tag DSL emits open-close pairs for non-void tags and
|
|
75
|
+
# self-closing for void tags automatically, so we just forward.
|
|
76
|
+
def generate_child_element(tag_name, stimulus_data_attributes, options, &block)
|
|
77
|
+
check_valid_html_tag!(tag_name)
|
|
78
|
+
options[:data] ||= {}
|
|
79
|
+
options[:data].merge!(stimulus_data_attributes)
|
|
80
|
+
send(tag_name, **options, &block)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "literal"
|
|
4
|
+
require_relative "naming"
|
|
5
|
+
require_relative "controller"
|
|
6
|
+
|
|
7
|
+
module Vident2
|
|
8
|
+
module Stimulus
|
|
9
|
+
# `data-action` fragment: single action descriptor like
|
|
10
|
+
# `"click->admin--users#handleClick"`.
|
|
11
|
+
#
|
|
12
|
+
# Fields folded in from v1's `StimulusAction::Descriptor` — there's no
|
|
13
|
+
# separate Descriptor class in V2; Hash DSL input parses directly into
|
|
14
|
+
# an `Action`.
|
|
15
|
+
class Action < ::Literal::Data
|
|
16
|
+
# Stimulus action options (`:once`, `:prevent`, etc.). Keep in sync
|
|
17
|
+
# with https://stimulus.hotwired.dev/reference/actions#options.
|
|
18
|
+
VALID_OPTIONS = %i[once prevent stop passive !passive capture self].freeze
|
|
19
|
+
|
|
20
|
+
prop :controller, Controller
|
|
21
|
+
prop :method_name, String
|
|
22
|
+
prop :event, _Nilable(String), default: nil
|
|
23
|
+
prop :modifiers, _Array(Symbol), default: -> { [] }
|
|
24
|
+
prop :keyboard, _Nilable(String), default: nil
|
|
25
|
+
prop :window, _Boolean, default: false
|
|
26
|
+
|
|
27
|
+
# `.parse(*args, implied:)` grammar mirrors v1 `StimulusAction#parse_arguments`:
|
|
28
|
+
# (Symbol) -> :method on implied controller, no event
|
|
29
|
+
# (String) -> pre-qualified "event->ctrl#method" / "ctrl#method"
|
|
30
|
+
# (Hash) -> keyword descriptor (method:/event:/...)
|
|
31
|
+
# (Symbol, Symbol) -> (event, method) on implied
|
|
32
|
+
# (String, Symbol) -> (controller_path, method) — no event
|
|
33
|
+
# (Symbol, String, Symbol) -> (event, controller_path, method)
|
|
34
|
+
def self.parse(*args, implied:, component_id: nil)
|
|
35
|
+
case args
|
|
36
|
+
in [Hash => h]
|
|
37
|
+
from_descriptor(h, implied: implied)
|
|
38
|
+
in [Symbol => method_sym]
|
|
39
|
+
new(
|
|
40
|
+
controller: implied,
|
|
41
|
+
method_name: Naming.js_name(method_sym),
|
|
42
|
+
event: nil
|
|
43
|
+
)
|
|
44
|
+
in [String => s]
|
|
45
|
+
parse_qualified_string(s)
|
|
46
|
+
in [Symbol => event, Symbol => method_sym]
|
|
47
|
+
new(
|
|
48
|
+
controller: implied,
|
|
49
|
+
method_name: Naming.js_name(method_sym),
|
|
50
|
+
event: event.to_s
|
|
51
|
+
)
|
|
52
|
+
in [String => ctrl_path, Symbol => method_sym]
|
|
53
|
+
new(
|
|
54
|
+
controller: Controller.parse(ctrl_path, implied: implied),
|
|
55
|
+
method_name: Naming.js_name(method_sym),
|
|
56
|
+
event: nil
|
|
57
|
+
)
|
|
58
|
+
in [Symbol => event, String => ctrl_path, Symbol => method_sym]
|
|
59
|
+
new(
|
|
60
|
+
controller: Controller.parse(ctrl_path, implied: implied),
|
|
61
|
+
method_name: Naming.js_name(method_sym),
|
|
62
|
+
event: event.to_s
|
|
63
|
+
)
|
|
64
|
+
else
|
|
65
|
+
raise ::Vident2::ParseError, "Action.parse: invalid arguments #{args.inspect}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Serialised descriptor, e.g. `"click.esc:prevent@window->foo--bar#handle"`.
|
|
70
|
+
def to_s
|
|
71
|
+
head =
|
|
72
|
+
if event
|
|
73
|
+
ev = event.to_s
|
|
74
|
+
ev = "#{ev}.#{keyboard}" if keyboard
|
|
75
|
+
ev = "#{ev}#{modifiers.map { |o| ":#{o}" }.join}" if modifiers.any?
|
|
76
|
+
ev = "#{ev}@window" if window
|
|
77
|
+
"#{ev}->"
|
|
78
|
+
else
|
|
79
|
+
""
|
|
80
|
+
end
|
|
81
|
+
"#{head}#{controller.name}##{method_name}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def to_data_pair = [:action, to_s]
|
|
85
|
+
|
|
86
|
+
def to_h = {action: to_s}
|
|
87
|
+
alias_method :to_hash, :to_h
|
|
88
|
+
|
|
89
|
+
# Actions space-join under a single `:action` key, preserving order.
|
|
90
|
+
def self.to_data_hash(actions)
|
|
91
|
+
return {} if actions.empty?
|
|
92
|
+
{action: actions.map(&:to_s).join(" ")}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `.parse({event:, method:, controller:, options:, keyboard:, window:})`
|
|
96
|
+
# Keyword-descriptor entry point, used by the DSL Hash form.
|
|
97
|
+
def self.from_descriptor(h, implied:)
|
|
98
|
+
invalid_options = Array(h[:options]) - VALID_OPTIONS
|
|
99
|
+
unless invalid_options.empty?
|
|
100
|
+
raise ::Vident2::ParseError,
|
|
101
|
+
"Action.parse: invalid option(s) #{invalid_options.inspect}. Valid: #{VALID_OPTIONS.inspect}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
method_raw = h.fetch(:method)
|
|
105
|
+
method_name = method_raw.is_a?(Symbol) ? Naming.js_name(method_raw) : method_raw.to_s
|
|
106
|
+
controller = h[:controller] ? Controller.parse(h[:controller], implied: implied) : implied
|
|
107
|
+
new(
|
|
108
|
+
controller: controller,
|
|
109
|
+
method_name: method_name,
|
|
110
|
+
event: h[:event]&.to_s,
|
|
111
|
+
modifiers: Array(h[:options]),
|
|
112
|
+
keyboard: h[:keyboard],
|
|
113
|
+
window: h.fetch(:window, false)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Pre-qualified string form, e.g. `"click->admin/users#show"` or
|
|
118
|
+
# `"admin--users#show"`. Pass-through: the controller segment is NOT
|
|
119
|
+
# re-stimulized. Flagged for deprecation.
|
|
120
|
+
def self.parse_qualified_string(s)
|
|
121
|
+
if s.include?("->")
|
|
122
|
+
event_part, ctrl_method = s.split("->", 2)
|
|
123
|
+
ctrl, method = ctrl_method.split("#", 2)
|
|
124
|
+
new(
|
|
125
|
+
controller: Controller.new(path: ctrl, name: ctrl),
|
|
126
|
+
method_name: method,
|
|
127
|
+
event: event_part
|
|
128
|
+
)
|
|
129
|
+
else
|
|
130
|
+
ctrl, method = s.split("#", 2)
|
|
131
|
+
new(
|
|
132
|
+
controller: Controller.new(path: ctrl, name: ctrl),
|
|
133
|
+
method_name: method,
|
|
134
|
+
event: nil
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "literal"
|
|
4
|
+
require_relative "naming"
|
|
5
|
+
require_relative "controller"
|
|
6
|
+
|
|
7
|
+
module Vident2
|
|
8
|
+
module Stimulus
|
|
9
|
+
# `data-<ctrl>-<name>-class` fragment — a named CSS class set readable
|
|
10
|
+
# on the JS side as `this.<name>Class`. Renamed from v1's
|
|
11
|
+
# `StimulusClass` (which collided with Ruby's `Class` — uncomfortable
|
|
12
|
+
# to type in user code).
|
|
13
|
+
#
|
|
14
|
+
# `css` holds the final serialised string form (space-joined); the
|
|
15
|
+
# parser normalises String / Array-of-String / Array-of-anything inputs
|
|
16
|
+
# down to one shape so the Draft/Plan doesn't have to care.
|
|
17
|
+
class ClassMap < ::Literal::Data
|
|
18
|
+
prop :controller, Controller
|
|
19
|
+
prop :name, String
|
|
20
|
+
prop :css, String
|
|
21
|
+
|
|
22
|
+
def self.parse(*args, implied:, component_id: nil)
|
|
23
|
+
case args
|
|
24
|
+
in [Symbol => name_sym, css_input]
|
|
25
|
+
new(
|
|
26
|
+
controller: implied,
|
|
27
|
+
name: name_sym.to_s.dasherize,
|
|
28
|
+
css: normalize_css(css_input)
|
|
29
|
+
)
|
|
30
|
+
in [String => ctrl_path, Symbol => name_sym, css_input]
|
|
31
|
+
new(
|
|
32
|
+
controller: Controller.parse(ctrl_path, implied: implied),
|
|
33
|
+
name: name_sym.to_s.dasherize,
|
|
34
|
+
css: normalize_css(css_input)
|
|
35
|
+
)
|
|
36
|
+
else
|
|
37
|
+
raise ::Vident2::ParseError, "ClassMap.parse: invalid arguments #{args.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.normalize_css(input)
|
|
42
|
+
case input
|
|
43
|
+
when String
|
|
44
|
+
input.split(/\s+/).reject(&:empty?).join(" ")
|
|
45
|
+
when Array
|
|
46
|
+
input.map(&:to_s).reject(&:empty?).join(" ")
|
|
47
|
+
else
|
|
48
|
+
raise ::Vident2::ParseError, "ClassMap.parse: css must be a String or Array, got #{input.class}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s = css
|
|
53
|
+
|
|
54
|
+
def data_attribute_key = :"#{controller.name}-#{name}-class"
|
|
55
|
+
|
|
56
|
+
def to_data_pair = [data_attribute_key, css]
|
|
57
|
+
|
|
58
|
+
def to_h = {data_attribute_key => css}
|
|
59
|
+
alias_method :to_hash, :to_h
|
|
60
|
+
|
|
61
|
+
def self.to_data_hash(maps)
|
|
62
|
+
maps.each_with_object({}) do |m, acc|
|
|
63
|
+
key, str = m.to_data_pair
|
|
64
|
+
acc[key] = str
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vident2
|
|
4
|
+
module Stimulus
|
|
5
|
+
# Tiny aggregation wrapper for the plural `stimulus_<kind>s` parsers.
|
|
6
|
+
# Parametric over `kind`: the kind decides the per-element combining
|
|
7
|
+
# rule via its value class's `.to_data_hash`. Users interact with
|
|
8
|
+
# this object by splatting `{**component.stimulus_targets(...)}` into
|
|
9
|
+
# a `data:` option on a tag, so `#to_h` is the single required shape.
|
|
10
|
+
class Collection
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
attr_reader :kind, :items
|
|
14
|
+
|
|
15
|
+
def initialize(kind:, items:)
|
|
16
|
+
@kind = kind
|
|
17
|
+
@items = items.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def each(&block) = @items.each(&block)
|
|
21
|
+
def to_a = @items.dup
|
|
22
|
+
def size = @items.size
|
|
23
|
+
def length = @items.size
|
|
24
|
+
def empty? = @items.empty?
|
|
25
|
+
def any? = @items.any?
|
|
26
|
+
|
|
27
|
+
# Delegates to the kind's `.to_data_hash` — same path AttributeWriter
|
|
28
|
+
# uses at render time.
|
|
29
|
+
def to_h
|
|
30
|
+
@kind.value_class.to_data_hash(@items)
|
|
31
|
+
end
|
|
32
|
+
alias_method :to_hash, :to_h
|
|
33
|
+
|
|
34
|
+
def merge(other)
|
|
35
|
+
unless other.is_a?(self.class) && other.kind == @kind
|
|
36
|
+
raise ArgumentError, "Collection#merge: can only merge with same-kind Collection"
|
|
37
|
+
end
|
|
38
|
+
self.class.new(kind: @kind, items: @items + other.items)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|