vident 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +49 -18
  4. data/lib/vident/caching.rb +4 -110
  5. data/lib/vident/capabilities/caching.rb +98 -0
  6. data/lib/vident/capabilities/child_element_rendering.rb +92 -0
  7. data/lib/vident/capabilities/class_list_building.rb +23 -0
  8. data/lib/vident/capabilities/declarable.rb +39 -0
  9. data/lib/vident/capabilities/identifiable.rb +54 -0
  10. data/lib/vident/capabilities/inspectable.rb +17 -0
  11. data/lib/vident/capabilities/root_element_rendering.rb +31 -0
  12. data/lib/vident/capabilities/stimulus_data_emitting.rb +98 -0
  13. data/lib/vident/capabilities/stimulus_declaring.rb +79 -0
  14. data/lib/vident/capabilities/stimulus_draft.rb +51 -0
  15. data/lib/vident/capabilities/stimulus_mutation.rb +60 -0
  16. data/lib/vident/capabilities/stimulus_parsing.rb +144 -0
  17. data/lib/vident/capabilities/tailwind.rb +18 -0
  18. data/lib/vident/component.rb +14 -76
  19. data/lib/vident/engine.rb +6 -5
  20. data/lib/vident/error.rb +16 -0
  21. data/lib/vident/internals/action_builder.rb +97 -0
  22. data/lib/vident/internals/attribute_writer.rb +17 -0
  23. data/lib/vident/internals/class_list_builder.rb +62 -0
  24. data/lib/vident/internals/declaration.rb +13 -0
  25. data/lib/vident/internals/declarations.rb +64 -0
  26. data/lib/vident/internals/draft.rb +47 -0
  27. data/lib/vident/internals/dsl.rb +172 -0
  28. data/lib/vident/internals/plan.rb +9 -0
  29. data/lib/vident/internals/registry.rb +37 -0
  30. data/lib/vident/internals/resolver.rb +316 -0
  31. data/lib/vident/internals/target_builder.rb +23 -0
  32. data/lib/vident/stable_id.rb +3 -3
  33. data/lib/vident/stimulus/action.rb +127 -0
  34. data/lib/vident/stimulus/base.rb +26 -0
  35. data/lib/vident/stimulus/class_map.rb +57 -0
  36. data/lib/vident/stimulus/collection.rb +40 -0
  37. data/lib/vident/stimulus/combinable.rb +30 -0
  38. data/lib/vident/stimulus/controller.rb +45 -0
  39. data/lib/vident/stimulus/naming.rb +9 -9
  40. data/lib/vident/stimulus/null.rb +7 -0
  41. data/lib/vident/stimulus/outlet.rb +93 -0
  42. data/lib/vident/stimulus/param.rb +56 -0
  43. data/lib/vident/stimulus/target.rb +48 -0
  44. data/lib/vident/stimulus/value.rb +57 -0
  45. data/lib/vident/stimulus_null.rb +4 -8
  46. data/lib/vident/tailwind.rb +4 -17
  47. data/lib/vident/types.rb +28 -0
  48. data/lib/vident/version.rb +1 -6
  49. data/lib/vident.rb +44 -36
  50. data/skills/vident/SKILL.md +133 -21
  51. data/skills/vident/api-reference.md +662 -0
  52. data/skills/vident/examples.md +505 -0
  53. metadata +40 -28
  54. data/lib/vident/child_element_helper.rb +0 -64
  55. data/lib/vident/class_list_builder.rb +0 -112
  56. data/lib/vident/component_attribute_resolver.rb +0 -87
  57. data/lib/vident/component_class_lists.rb +0 -34
  58. data/lib/vident/stimulus/primitive.rb +0 -38
  59. data/lib/vident/stimulus.rb +0 -31
  60. data/lib/vident/stimulus_action.rb +0 -133
  61. data/lib/vident/stimulus_action_collection.rb +0 -11
  62. data/lib/vident/stimulus_attribute_base.rb +0 -67
  63. data/lib/vident/stimulus_attributes.rb +0 -129
  64. data/lib/vident/stimulus_builder.rb +0 -119
  65. data/lib/vident/stimulus_class.rb +0 -59
  66. data/lib/vident/stimulus_class_collection.rb +0 -11
  67. data/lib/vident/stimulus_collection_base.rb +0 -51
  68. data/lib/vident/stimulus_component.rb +0 -75
  69. data/lib/vident/stimulus_controller.rb +0 -41
  70. data/lib/vident/stimulus_controller_collection.rb +0 -14
  71. data/lib/vident/stimulus_data_attribute_builder.rb +0 -32
  72. data/lib/vident/stimulus_helper.rb +0 -66
  73. data/lib/vident/stimulus_outlet.rb +0 -90
  74. data/lib/vident/stimulus_outlet_collection.rb +0 -11
  75. data/lib/vident/stimulus_param.rb +0 -42
  76. data/lib/vident/stimulus_param_collection.rb +0 -11
  77. data/lib/vident/stimulus_target.rb +0 -47
  78. data/lib/vident/stimulus_target_collection.rb +0 -18
  79. data/lib/vident/stimulus_value.rb +0 -39
  80. data/lib/vident/stimulus_value_collection.rb +0 -11
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "draft"
5
+
6
+ module Vident
7
+ module Internals
8
+ # Resolves raw Declarations and instance state into a Draft of typed Stimulus values.
9
+ # Phase `:static` skips proc-bearing entries; `:procs` processes only those; `:all` does both.
10
+ # Procs nested inside a Hash descriptor escape the phase gate — use the fluent builder instead.
11
+ module Resolver
12
+ module_function
13
+
14
+ def call(declarations, instance, phase: :all)
15
+ raise ArgumentError, "use resolve_procs_into for phase: :procs" if phase == :procs
16
+
17
+ draft = Draft.new
18
+ implied = build_implied_controller(instance)
19
+ alias_map = build_alias_map(declarations)
20
+
21
+ seed_implied_controller(draft, instance)
22
+ resolve_declarations(draft, declarations, instance, implied, phase:, alias_map:)
23
+ absorb_stimulus_props(draft, instance, implied, phase:, alias_map:)
24
+ absorb_root_element_attributes(draft, instance, implied, phase:, alias_map:)
25
+
26
+ draft
27
+ end
28
+
29
+ def resolve_procs_into(draft, declarations, instance)
30
+ implied = build_implied_controller(instance)
31
+ alias_map = build_alias_map(declarations)
32
+ resolve_declarations(draft, declarations, instance, implied, phase: :procs, alias_map:)
33
+ absorb_stimulus_props(draft, instance, implied, phase: :procs, alias_map:)
34
+ absorb_root_element_attributes(draft, instance, implied, phase: :procs, alias_map:)
35
+ draft
36
+ end
37
+
38
+ def build_alias_map(declarations)
39
+ map = {}
40
+ declarations.controllers.each do |decl|
41
+ alias_name = decl.meta[:as]
42
+ next unless alias_name
43
+ raw_path = decl.args.first
44
+ next if raw_path.nil?
45
+ map[alias_name] = raw_path.to_s
46
+ end
47
+ map
48
+ end
49
+
50
+ def build_implied_controller(instance)
51
+ path = instance.class.stimulus_identifier_path
52
+ name = instance.class.stimulus_identifier
53
+ ::Vident::Stimulus::Controller.new(path: path, name: name)
54
+ end
55
+
56
+ def seed_implied_controller(draft, instance)
57
+ return unless instance.class.stimulus_controller?
58
+ draft.add_controllers(build_implied_controller(instance))
59
+ end
60
+
61
+ def resolve_declarations(draft, declarations, instance, implied, phase:, alias_map: {})
62
+ resolve_positional(draft, :controllers, declarations.controllers, instance, phase:) do |args, meta, _inst|
63
+ ::Vident::Stimulus::Controller.parse(*args, implied: implied, **meta_for_controller(meta))
64
+ end
65
+
66
+ resolve_positional(draft, :actions, declarations.actions, instance, phase:) do |args, _meta, _inst|
67
+ parse_single(::Vident::Stimulus::Action, resolve_action_aliases(args, alias_map), implied: implied, component_id: instance_id(instance))
68
+ end
69
+
70
+ resolve_positional(draft, :targets, declarations.targets, instance, phase:) do |args, _meta, _inst|
71
+ parse_single(::Vident::Stimulus::Target, args, implied: implied, component_id: instance_id(instance))
72
+ end
73
+
74
+ resolve_keyed(draft, :outlets, declarations.outlets, instance, phase:) do |key, args, _meta|
75
+ parsed_args = [key_for_parse(key), *args]
76
+ parse_single(::Vident::Stimulus::Outlet, parsed_args, implied: implied, component_id: instance_id(instance))
77
+ end
78
+
79
+ resolve_keyed_values(draft, declarations, instance, implied, phase:)
80
+ resolve_keyed_scalars(draft, :params, declarations.params, instance, implied, ::Vident::Stimulus::Param, phase:)
81
+ resolve_keyed_scalars(draft, :class_maps, declarations.class_maps, instance, implied, ::Vident::Stimulus::ClassMap, phase:)
82
+ end
83
+
84
+ # Unknown aliases raise — a symbolic controller ref is declared intent, not a guess.
85
+ def resolve_action_aliases(args, alias_map)
86
+ return args if alias_map.empty?
87
+ args.map do |arg|
88
+ next arg unless arg.is_a?(Hash) && arg[:controller].is_a?(Symbol)
89
+ sym = arg[:controller]
90
+ unless alias_map.key?(sym)
91
+ raise ::Vident::DeclarationError, "Unknown controller alias :#{sym} in action. Declared aliases: #{alias_map.keys.inspect}"
92
+ end
93
+ arg.merge(controller: alias_map[sym])
94
+ end
95
+ end
96
+
97
+ def resolve_positional(draft, kind, entries, instance, phase:)
98
+ entries.each do |decl|
99
+ next unless phase_matches?(decl, phase)
100
+ next if gated_out?(decl.when_proc, instance)
101
+ args = resolve_args(decl.args, instance)
102
+ next if args.nil?
103
+ args = splat_single_array(args)
104
+ parsed = yield(args, decl.meta, instance)
105
+ draft.public_send(:"add_#{kind}", parsed) if parsed
106
+ end
107
+ end
108
+
109
+ def splat_single_array(args)
110
+ (args.size == 1 && args[0].is_a?(Array)) ? args[0] : args
111
+ end
112
+
113
+ def resolve_keyed(draft, kind, entries, instance, phase:)
114
+ entries.each do |(key, decl)|
115
+ next unless phase_matches?(decl, phase)
116
+ next if gated_out?(decl.when_proc, instance)
117
+ args = resolve_args(decl.args, instance)
118
+ next if args.nil?
119
+ parsed = yield(key, args, decl.meta)
120
+ draft.public_send(:"add_#{kind}", parsed) if parsed
121
+ end
122
+ end
123
+
124
+ def resolve_keyed_values(draft, declarations, instance, implied, phase:)
125
+ declarations.values.each do |(key, decl)|
126
+ next unless phase_matches?(decl, phase)
127
+ next if gated_out?(decl.when_proc, instance)
128
+
129
+ if decl.meta[:from_prop]
130
+ raw = read_prop(instance, key)
131
+ next if raw.nil?
132
+ draft.add_values(::Vident::Stimulus::Value.parse(key, raw, implied: implied))
133
+ next
134
+ end
135
+
136
+ raw = resolve_value_meta(decl, instance)
137
+ next if raw.nil?
138
+ draft.add_values(::Vident::Stimulus::Value.parse(key, raw, implied: implied))
139
+ end
140
+
141
+ # values_from_props has no when_proc, so phase_matches? doesn't apply; skip on :procs pass.
142
+ return if phase == :procs
143
+ declarations.values_from_props.each do |name|
144
+ raw = read_prop(instance, name)
145
+ next if raw.nil?
146
+ draft.add_values(::Vident::Stimulus::Value.parse(name, raw, implied: implied))
147
+ end
148
+ end
149
+
150
+ def resolve_keyed_scalars(draft, kind, entries, instance, implied, value_class, phase:)
151
+ entries.each do |(key, decl)|
152
+ next unless phase_matches?(decl, phase)
153
+ next if gated_out?(decl.when_proc, instance)
154
+ raw = resolve_value_meta(decl, instance)
155
+ next if raw.nil?
156
+ draft.public_send(:"add_#{kind}", value_class.parse(key, raw, implied: implied))
157
+ end
158
+ end
159
+
160
+ def phase_matches?(decl, phase)
161
+ return true if phase == :all
162
+ has_proc = decl.when_proc || decl.args.any? { |a| a.is_a?(Proc) }
163
+ (phase == :procs) ? has_proc : !has_proc
164
+ end
165
+
166
+ def absorb_stimulus_props(draft, instance, implied, phase:, alias_map: {})
167
+ absorb_input(draft, :controllers, instance_ivar(instance, :@stimulus_controllers), instance, implied, phase:, alias_map:)
168
+ absorb_input(draft, :actions, instance_ivar(instance, :@stimulus_actions), instance, implied, phase:, alias_map:)
169
+ absorb_input(draft, :targets, instance_ivar(instance, :@stimulus_targets), instance, implied, phase:, alias_map:)
170
+ absorb_input(draft, :outlets, instance_ivar(instance, :@stimulus_outlets), instance, implied, phase:, alias_map:)
171
+ absorb_input(draft, :values, instance_ivar(instance, :@stimulus_values), instance, implied, phase:, alias_map:)
172
+ absorb_input(draft, :params, instance_ivar(instance, :@stimulus_params), instance, implied, phase:, alias_map:)
173
+ absorb_input(draft, :class_maps, instance_ivar(instance, :@stimulus_classes), instance, implied, phase:, alias_map:)
174
+ end
175
+
176
+ def absorb_root_element_attributes(draft, instance, implied, phase:, alias_map: {})
177
+ return unless instance.respond_to?(:resolved_root_element_attributes, true)
178
+ attrs = instance.send(:resolved_root_element_attributes)
179
+ return unless attrs.is_a?(Hash) && !attrs.empty?
180
+
181
+ absorb_input(draft, :controllers, attrs[:stimulus_controllers], instance, implied, phase:, alias_map:)
182
+ absorb_input(draft, :actions, attrs[:stimulus_actions], instance, implied, phase:, alias_map:)
183
+ absorb_input(draft, :targets, attrs[:stimulus_targets], instance, implied, phase:, alias_map:)
184
+ absorb_input(draft, :outlets, attrs[:stimulus_outlets], instance, implied, phase:, alias_map:)
185
+ absorb_input(draft, :values, attrs[:stimulus_values], instance, implied, phase:, alias_map:)
186
+ absorb_input(draft, :params, attrs[:stimulus_params], instance, implied, phase:, alias_map:)
187
+ absorb_input(draft, :class_maps, attrs[:stimulus_classes], instance, implied, phase:, alias_map:)
188
+ end
189
+
190
+ def absorb_input(draft, kind, input, instance, implied, phase:, alias_map: {})
191
+ return if input.nil?
192
+
193
+ kind_meta = Registry.fetch(kind)
194
+ case input
195
+ in Hash => h
196
+ h.each do |key, raw|
197
+ is_proc = raw.is_a?(Proc)
198
+ next unless phase_allows?(is_proc, phase)
199
+ absorbed = is_proc ? instance.instance_exec(&raw) : raw
200
+ next if absorbed.nil?
201
+ if kind_meta.keyed?
202
+ parsed = kind_meta.value_class.parse(key, absorbed, implied: implied, component_id: instance_id(instance))
203
+ draft.public_send(:"add_#{kind}", parsed)
204
+ else
205
+ entry = resolve_absorb_alias(kind, [key, absorbed], alias_map)
206
+ parsed = parse_entry(kind_meta, entry, implied: implied, component_id: instance_id(instance))
207
+ draft.public_send(:"add_#{kind}", parsed) if parsed
208
+ end
209
+ end
210
+ in Array => a
211
+ a.each do |entry|
212
+ is_proc = entry.is_a?(Proc)
213
+ next unless phase_allows?(is_proc, phase)
214
+ parsed = absorb_one(kind_meta, entry, instance, implied, kind:, alias_map:)
215
+ draft.public_send(:"add_#{kind}", parsed) if parsed
216
+ end
217
+ else
218
+ is_proc = input.is_a?(Proc)
219
+ return unless phase_allows?(is_proc, phase)
220
+ parsed = absorb_one(kind_meta, input, instance, implied, kind:, alias_map:)
221
+ draft.public_send(:"add_#{kind}", parsed) if parsed
222
+ end
223
+ end
224
+
225
+ def resolve_absorb_alias(kind, entry, alias_map)
226
+ return entry unless kind == :actions && alias_map.any?
227
+ return entry unless entry.is_a?(Hash) && entry[:controller].is_a?(Symbol)
228
+ sym = entry[:controller]
229
+ unless alias_map.key?(sym)
230
+ raise ::Vident::DeclarationError, "Unknown controller alias :#{sym} in stimulus_actions input. Declared aliases: #{alias_map.keys.inspect}"
231
+ end
232
+ entry.merge(controller: alias_map[sym])
233
+ end
234
+
235
+ # Uses pattern matching so an unknown phase raises NoMatchingPatternError early.
236
+ def phase_allows?(is_proc, phase)
237
+ case phase
238
+ in :all then true
239
+ in :static then !is_proc
240
+ in :procs then is_proc
241
+ end
242
+ end
243
+
244
+ def absorb_one(kind_meta, entry, instance, implied, kind: nil, alias_map: {})
245
+ entry = instance.instance_exec(&entry) if entry.is_a?(Proc)
246
+ return nil if entry.nil?
247
+ return entry if entry.is_a?(kind_meta.value_class)
248
+
249
+ entry = resolve_absorb_alias(kind, entry, alias_map) if kind
250
+ parse_entry(kind_meta, entry, implied: implied, component_id: instance_id(instance))
251
+ end
252
+
253
+ def parse_entry(kind_meta, entry, implied:, component_id:)
254
+ case entry
255
+ when Hash
256
+ if kind_meta.keyed?
257
+ first_key, first_val = entry.first
258
+ kind_meta.value_class.parse(first_key, first_val, implied: implied, component_id: component_id)
259
+ else
260
+ kind_meta.value_class.parse(entry, implied: implied, component_id: component_id)
261
+ end
262
+ when Array
263
+ kind_meta.value_class.parse(*entry, implied: implied, component_id: component_id)
264
+ else
265
+ kind_meta.value_class.parse(entry, implied: implied, component_id: component_id)
266
+ end
267
+ end
268
+
269
+ # Only nil drops — false, blank strings, and empty collections reach the parser.
270
+ def resolve_args(args, instance)
271
+ resolved = args.map { |arg| arg.is_a?(Proc) ? instance.instance_exec(&arg) : arg }
272
+ return nil if resolved.any?(&:nil?)
273
+ resolved
274
+ end
275
+
276
+ def resolve_value_meta(decl, instance)
277
+ return decl.meta[:static] if decl.meta.key?(:static)
278
+ return nil if decl.args.empty?
279
+
280
+ raw = decl.args.first
281
+ raw = instance.instance_exec(&raw) if raw.is_a?(Proc)
282
+ raw
283
+ end
284
+
285
+ def gated_out?(when_proc, instance)
286
+ return false unless when_proc
287
+ !instance.instance_exec(&when_proc)
288
+ end
289
+
290
+ def parse_single(value_class, args, implied:, component_id:)
291
+ value_class.parse(*args, implied: implied, component_id: component_id)
292
+ end
293
+
294
+ def key_for_parse(key) = key
295
+
296
+ def meta_for_controller(meta) = meta.slice(:as)
297
+
298
+ def instance_ivar(instance, name)
299
+ return nil unless instance.instance_variable_defined?(name)
300
+ instance.instance_variable_get(name)
301
+ end
302
+
303
+ # Must match the mutation-API path (#id, memoised) so DSL outlet
304
+ # auto-selectors and runtime-added outlets scope identically.
305
+ def instance_id(instance)
306
+ instance.id
307
+ end
308
+
309
+ def read_prop(instance, name)
310
+ ivar = :"@#{name}"
311
+ return nil unless instance.instance_variable_defined?(ivar)
312
+ instance.instance_variable_get(ivar)
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "declaration"
4
+
5
+ module Vident
6
+ module Internals
7
+ class TargetBuilder
8
+ def initialize(*args)
9
+ @args = args
10
+ @when_proc = nil
11
+ end
12
+
13
+ def when(callable = nil, &block)
14
+ @when_proc = block || callable
15
+ self
16
+ end
17
+
18
+ def to_declaration
19
+ Declaration.new(args: @args.freeze, when_proc: @when_proc, meta: {}.freeze)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -5,9 +5,9 @@ require "digest/md5"
5
5
 
6
6
  module Vident
7
7
  class StableId
8
- class GeneratorNotSetError < StandardError; end
8
+ class GeneratorNotSetError < ::Vident::ConfigurationError; end
9
9
 
10
- class StrategyNotConfiguredError < StandardError; end
10
+ class StrategyNotConfiguredError < ::Vident::ConfigurationError; end
11
11
 
12
12
  RANDOM_FALLBACK = ->(generator) do
13
13
  return Random.hex(16) unless generator
@@ -25,7 +25,7 @@ module Vident
25
25
  end
26
26
 
27
27
  class << self
28
- # Callable(generator_or_nil) -> String. Starts nil; host app must configure it.
28
+ # Callable(generator_or_nil) -> String. Host app must configure before first render.
29
29
  attr_accessor :strategy
30
30
 
31
31
  def set_current_sequence_generator(seed:)
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+ require_relative "naming"
5
+ require_relative "controller"
6
+ require_relative "base"
7
+
8
+ module Vident
9
+ module Stimulus
10
+ # `data-action` fragment: single action descriptor like
11
+ # `"click->admin--users#handleClick"`.
12
+ class Action < Base
13
+ # Keep in sync with https://stimulus.hotwired.dev/reference/actions#options.
14
+ VALID_OPTIONS = %i[once prevent stop passive !passive capture self].freeze
15
+
16
+ prop :controller, Controller
17
+ prop :method_name, String
18
+ prop :event, _Nilable(String), default: nil
19
+ prop :modifiers, _Array(Symbol), default: -> { [] }
20
+ prop :keyboard, _Nilable(String), default: nil
21
+ prop :window, _Boolean, default: false
22
+
23
+ def self.parse(*args, implied:, component_id: nil)
24
+ case args
25
+ in [Action => a]
26
+ a
27
+ in [Symbol => event, Action => a]
28
+ a.with(event: event.to_s)
29
+ in [Hash => h]
30
+ from_descriptor(h, implied: implied)
31
+ in [Symbol => method_sym]
32
+ new(
33
+ controller: implied,
34
+ method_name: Naming.js_name(method_sym),
35
+ event: nil
36
+ )
37
+ in [String => s]
38
+ parse_qualified_string(s)
39
+ in [Symbol => event, Symbol => method_sym]
40
+ new(
41
+ controller: implied,
42
+ method_name: Naming.js_name(method_sym),
43
+ event: event.to_s
44
+ )
45
+ in [String => ctrl_path, Symbol => method_sym]
46
+ new(
47
+ controller: Controller.parse(ctrl_path, implied: implied),
48
+ method_name: Naming.js_name(method_sym),
49
+ event: nil
50
+ )
51
+ in [Symbol => event, String => ctrl_path, Symbol => method_sym]
52
+ new(
53
+ controller: Controller.parse(ctrl_path, implied: implied),
54
+ method_name: Naming.js_name(method_sym),
55
+ event: event.to_s
56
+ )
57
+ else
58
+ raise ::Vident::ParseError, "Action.parse: invalid arguments #{args.inspect}"
59
+ end
60
+ end
61
+
62
+ def to_s
63
+ head =
64
+ if event
65
+ ev = event.to_s
66
+ ev = "#{ev}.#{keyboard}" if keyboard
67
+ ev = "#{ev}#{modifiers.map { |o| ":#{o}" }.join}" if modifiers.any?
68
+ ev = "#{ev}@window" if window
69
+ "#{ev}->"
70
+ else
71
+ ""
72
+ end
73
+ "#{head}#{controller.name}##{method_name}"
74
+ end
75
+
76
+ def to_data_pair = [:action, to_s]
77
+
78
+ def to_h = {action: to_s}
79
+ alias_method :to_hash, :to_h
80
+
81
+ def self.to_data_hash(actions)
82
+ return {} if actions.empty?
83
+ {action: actions.map(&:to_s).join(" ")}
84
+ end
85
+
86
+ def self.from_descriptor(h, implied:)
87
+ invalid_options = Array(h[:options]) - VALID_OPTIONS
88
+ unless invalid_options.empty?
89
+ raise ::Vident::ParseError,
90
+ "Action.parse: invalid option(s) #{invalid_options.inspect}. Valid: #{VALID_OPTIONS.inspect}"
91
+ end
92
+
93
+ method_raw = h.fetch(:method)
94
+ method_name = method_raw.is_a?(Symbol) ? Naming.js_name(method_raw) : method_raw.to_s
95
+ controller = h[:controller] ? Controller.parse(h[:controller], implied: implied) : implied
96
+ new(
97
+ controller: controller,
98
+ method_name: method_name,
99
+ event: h[:event]&.to_s,
100
+ modifiers: Array(h[:options]),
101
+ keyboard: h[:keyboard],
102
+ window: h.fetch(:window, false)
103
+ )
104
+ end
105
+
106
+ # Pass-through: the controller segment is NOT re-stimulized.
107
+ def self.parse_qualified_string(s)
108
+ if s.include?("->")
109
+ event_part, ctrl_method = s.split("->", 2)
110
+ ctrl, method = ctrl_method.split("#", 2)
111
+ new(
112
+ controller: Controller.new(path: ctrl, name: ctrl),
113
+ method_name: method,
114
+ event: event_part
115
+ )
116
+ else
117
+ ctrl, method = s.split("#", 2)
118
+ new(
119
+ controller: Controller.new(path: ctrl, name: ctrl),
120
+ method_name: method,
121
+ event: nil
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+ require_relative "combinable"
5
+
6
+ module Vident
7
+ module Stimulus
8
+ # Shared frozen-value base for the Stimulus primitive value classes
9
+ # (Action, Target, Controller, Outlet, Value, Param, ClassMap).
10
+ # Provides `Combinable` (`with`, pattern-matching `deconstruct_keys`)
11
+ # and a default `to_data_hash(items)` that subclasses override when
12
+ # they need non-trivial collection semantics (space-join etc.).
13
+ #
14
+ # Subclasses still override `to_h` / `to_hash` per class — Literal
15
+ # auto-generates a prop-hash `to_h` from the prop DSL that would
16
+ # shadow any default here, so the data-attribute-shape override
17
+ # must live on each concrete class.
18
+ class Base < ::Literal::Data
19
+ include Combinable
20
+
21
+ def self.to_data_hash(items)
22
+ items.to_h(&:to_data_pair)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+ require_relative "naming"
5
+ require_relative "base"
6
+ require_relative "controller"
7
+
8
+ module Vident
9
+ module Stimulus
10
+ # `data-<ctrl>-<name>-class` fragment — a named CSS class set readable
11
+ # on the JS side as `this.<name>Class`.
12
+ class ClassMap < Base
13
+ prop :controller, Controller
14
+ prop :name, String
15
+ prop :css, String
16
+
17
+ def self.parse(*args, implied:, component_id: nil)
18
+ case args
19
+ in [Symbol => name_sym, css_input]
20
+ new(
21
+ controller: implied,
22
+ name: name_sym.to_s.dasherize,
23
+ css: normalize_css(css_input)
24
+ )
25
+ in [String => ctrl_path, Symbol => name_sym, css_input]
26
+ new(
27
+ controller: Controller.parse(ctrl_path, implied: implied),
28
+ name: name_sym.to_s.dasherize,
29
+ css: normalize_css(css_input)
30
+ )
31
+ else
32
+ raise ::Vident::ParseError, "ClassMap.parse: invalid arguments #{args.inspect}"
33
+ end
34
+ end
35
+
36
+ def self.normalize_css(input)
37
+ case input
38
+ when String
39
+ input.split(/\s+/).reject(&:empty?).join(" ")
40
+ when Array
41
+ input.map(&:to_s).reject(&:empty?).join(" ")
42
+ else
43
+ raise ::Vident::ParseError, "ClassMap.parse: css must be a String or Array, got #{input.class}"
44
+ end
45
+ end
46
+
47
+ def to_s = css
48
+
49
+ def data_attribute_key = :"#{controller.name}-#{name}-class"
50
+
51
+ def to_data_pair = [data_attribute_key, css]
52
+
53
+ def to_h = {data_attribute_key => css}
54
+ alias_method :to_hash, :to_h
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ class Collection
6
+ include Enumerable
7
+
8
+ attr_reader :kind, :items
9
+
10
+ def initialize(kind:, items:)
11
+ @kind = kind
12
+ @items = items.freeze
13
+ end
14
+
15
+ def each(&block) = @items.each(&block)
16
+
17
+ def to_a = @items.dup
18
+
19
+ def size = @items.size
20
+
21
+ def length = @items.size
22
+
23
+ def empty? = @items.empty?
24
+
25
+ def any? = @items.any?
26
+
27
+ def to_h
28
+ @kind.value_class.to_data_hash(@items)
29
+ end
30
+ alias_method :to_hash, :to_h
31
+
32
+ def merge(other)
33
+ unless other.is_a?(self.class) && other.kind == @kind
34
+ raise ArgumentError, "Collection#merge: can only merge with same-kind Collection"
35
+ end
36
+ self.class.new(kind: @kind, items: @items + other.items)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ # Shared `with(**overrides)` combinator for the frozen value classes.
6
+ # Mirrors Ruby's `Data.define#with` convention (which Literal::Data
7
+ # doesn't ship) so callers can decorate a value object without
8
+ # mutating it.
9
+ module Combinable
10
+ # Canonical Ruby Data-object hooks. The value classes override `to_h`
11
+ # to serialise to their data-attribute shape; without this module,
12
+ # `deconstruct_keys` would inherit that override and pattern-matching
13
+ # (`case a; in {event:}`) would silently fail.
14
+ def deconstruct_keys(keys)
15
+ h = self.class.literal_properties.properties_index.each_with_object({}) do |(name, _), acc|
16
+ acc[name.to_sym] = public_send(name)
17
+ end
18
+ keys ? h.slice(*keys) : h
19
+ end
20
+
21
+ def deconstruct
22
+ deconstruct_keys(nil).values
23
+ end
24
+
25
+ def with(**overrides)
26
+ self.class.new(**deconstruct_keys(nil).merge(overrides))
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+ require_relative "naming"
5
+ require_relative "base"
6
+
7
+ module Vident
8
+ module Stimulus
9
+ # `data-controller` fragment.
10
+ class Controller < Base
11
+ prop :path, String
12
+ prop :name, String
13
+ prop :alias_name, _Nilable(Symbol), default: nil
14
+
15
+ def self.parse(*args, implied:, as: nil, component_id: nil)
16
+ case args.size
17
+ when 0
18
+ new(path: implied.path, name: implied.name, alias_name: as)
19
+ when 1
20
+ raw = args[0]
21
+ path = raw.to_s
22
+ new(path: path, name: Naming.stimulize_path(path), alias_name: as)
23
+ else
24
+ raise ::Vident::ParseError, "Controller.parse: expected 0 or 1 positional args, got #{args.size}"
25
+ end
26
+ end
27
+
28
+ def identifier = name
29
+
30
+ def to_s = name
31
+
32
+ def to_data_pair = [:controller, name]
33
+
34
+ def to_h = {controller: name}
35
+ alias_method :to_hash, :to_h
36
+
37
+ def self.to_data_hash(controllers)
38
+ return {} if controllers.empty?
39
+ joined = controllers.map(&:name).reject(&:empty?).join(" ")
40
+ return {} if joined.empty?
41
+ {controller: joined}
42
+ end
43
+ end
44
+ end
45
+ end