vident 1.0.0 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +4 -1
  4. data/lib/vident/component_attribute_resolver.rb +27 -8
  5. data/lib/vident/component_class_lists.rb +7 -1
  6. data/lib/vident/stimulus_builder.rb +28 -11
  7. data/lib/vident/stimulus_helper.rb +4 -4
  8. data/lib/vident/version.rb +1 -1
  9. data/lib/vident2/caching.rb +93 -0
  10. data/lib/vident2/component.rb +538 -0
  11. data/lib/vident2/engine.rb +18 -0
  12. data/lib/vident2/error.rb +30 -0
  13. data/lib/vident2/internals/action_builder.rb +101 -0
  14. data/lib/vident2/internals/attribute_writer.rb +22 -0
  15. data/lib/vident2/internals/class_list_builder.rb +79 -0
  16. data/lib/vident2/internals/declaration.rb +17 -0
  17. data/lib/vident2/internals/declarations.rb +76 -0
  18. data/lib/vident2/internals/draft.rb +60 -0
  19. data/lib/vident2/internals/dsl.rb +198 -0
  20. data/lib/vident2/internals/plan.rb +12 -0
  21. data/lib/vident2/internals/registry.rb +41 -0
  22. data/lib/vident2/internals/resolver.rb +306 -0
  23. data/lib/vident2/internals/target_builder.rb +29 -0
  24. data/lib/vident2/phlex/html.rb +84 -0
  25. data/lib/vident2/phlex.rb +9 -0
  26. data/lib/vident2/stimulus/action.rb +140 -0
  27. data/lib/vident2/stimulus/class_map.rb +69 -0
  28. data/lib/vident2/stimulus/collection.rb +42 -0
  29. data/lib/vident2/stimulus/controller.rb +59 -0
  30. data/lib/vident2/stimulus/naming.rb +26 -0
  31. data/lib/vident2/stimulus/null.rb +16 -0
  32. data/lib/vident2/stimulus/outlet.rb +113 -0
  33. data/lib/vident2/stimulus/param.rb +62 -0
  34. data/lib/vident2/stimulus/target.rb +57 -0
  35. data/lib/vident2/stimulus/value.rb +77 -0
  36. data/lib/vident2/tailwind.rb +19 -0
  37. data/lib/vident2/version.rb +5 -0
  38. data/lib/vident2/view_component/base.rb +124 -0
  39. data/lib/vident2/view_component.rb +9 -0
  40. data/lib/vident2.rb +50 -0
  41. data/skills/vident/SKILL.md +11 -2
  42. data/skills/vident/api-reference.md +518 -0
  43. data/skills/vident/examples.md +492 -0
  44. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+ require "vident2/phlex/html"
5
+
6
+ module Vident2
7
+ module Phlex
8
+ end
9
+ 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