vident 1.0.0.beta1 → 1.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +177 -23
  4. data/lib/generators/vident/install/install_generator.rb +53 -0
  5. data/lib/generators/vident/install/templates/vident.rb +20 -0
  6. data/lib/vident/caching.rb +3 -9
  7. data/lib/vident/child_element_helper.rb +64 -0
  8. data/lib/vident/component.rb +4 -11
  9. data/lib/vident/component_attribute_resolver.rb +21 -36
  10. data/lib/vident/component_class_lists.rb +4 -3
  11. data/lib/vident/stable_id.rb +48 -17
  12. data/lib/vident/stimulus/naming.rb +19 -0
  13. data/lib/vident/stimulus/primitive.rb +38 -0
  14. data/lib/vident/stimulus.rb +31 -0
  15. data/lib/vident/stimulus_action.rb +58 -23
  16. data/lib/vident/stimulus_attribute_base.rb +27 -23
  17. data/lib/vident/stimulus_attributes.rb +56 -185
  18. data/lib/vident/stimulus_builder.rb +66 -87
  19. data/lib/vident/stimulus_class.rb +3 -9
  20. data/lib/vident/stimulus_class_collection.rb +1 -5
  21. data/lib/vident/stimulus_collection_base.rb +4 -12
  22. data/lib/vident/stimulus_component.rb +8 -7
  23. data/lib/vident/stimulus_controller.rb +10 -13
  24. data/lib/vident/stimulus_data_attribute_builder.rb +15 -74
  25. data/lib/vident/stimulus_helper.rb +4 -12
  26. data/lib/vident/stimulus_null.rb +21 -0
  27. data/lib/vident/stimulus_outlet.rb +4 -11
  28. data/lib/vident/stimulus_outlet_collection.rb +1 -5
  29. data/lib/vident/stimulus_param.rb +42 -0
  30. data/lib/vident/stimulus_param_collection.rb +11 -0
  31. data/lib/vident/stimulus_target.rb +7 -17
  32. data/lib/vident/stimulus_target_collection.rb +2 -6
  33. data/lib/vident/stimulus_value.rb +14 -44
  34. data/lib/vident/stimulus_value_collection.rb +1 -5
  35. data/lib/vident/tailwind.rb +0 -2
  36. data/lib/vident/version.rb +1 -1
  37. data/lib/vident.rb +8 -13
  38. data/skills/vident/SKILL.md +628 -0
  39. metadata +11 -2
  40. data/lib/vident/tag_helper.rb +0 -65
@@ -13,10 +13,11 @@ module Vident
13
13
 
14
14
  private
15
15
 
16
- # Get or create a class list builder instance
17
- # Automatically detects if Tailwind module is included and TailwindMerge gem is available
16
+ # Not memoised: the per-thread TailwindMerger is the only expensive piece
17
+ # and it's already cached; the builder itself just copies a few ivars.
18
+ # Memoising here would latch the first caller's `root_element_html_class:`.
18
19
  def class_list_builder(root_element_html_class = nil)
19
- @class_list_builder ||= ClassListBuilder.new(
20
+ ClassListBuilder.new(
20
21
  tailwind_merger:,
21
22
  component_name:,
22
23
  root_element_attributes_classes: @root_element_attributes_classes,
@@ -1,39 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- # Introduced in Ruby 3.1
5
- require "random/formatter"
6
- rescue LoadError
7
- # to support Ruby 3.0
8
- require "securerandom"
9
- end
3
+ require "random/formatter"
4
+ require "digest/md5"
10
5
 
11
6
  module Vident
12
7
  class StableId
8
+ class GeneratorNotSetError < StandardError; end
9
+
10
+ class StrategyNotConfiguredError < StandardError; end
11
+
12
+ RANDOM_FALLBACK = ->(generator) do
13
+ return Random.hex(16) unless generator
14
+ generator.next.join("-")
15
+ end
16
+
17
+ STRICT = ->(generator) do
18
+ unless generator
19
+ raise GeneratorNotSetError,
20
+ "No Vident::StableId sequence generator is set on the current thread. " \
21
+ "Call Vident::StableId.set_current_sequence_generator(seed: ...) in a " \
22
+ "before_action (or wrap the render in StableId.with_sequence_generator)."
23
+ end
24
+ generator.next.join("-")
25
+ end
26
+
13
27
  class << self
14
- def set_current_sequence_generator
15
- ::Thread.current[:vident_number_sequence_generator] = id_sequence_generator
28
+ # Callable(generator_or_nil) -> String. Starts nil; host app must configure it.
29
+ attr_accessor :strategy
30
+
31
+ def set_current_sequence_generator(seed:)
32
+ ::Thread.current[:vident_number_sequence_generator] = id_sequence_generator(seed)
16
33
  end
17
- alias_method :new_current_sequence_generator, :set_current_sequence_generator
18
34
 
19
35
  def clear_current_sequence_generator
20
36
  ::Thread.current[:vident_number_sequence_generator] = nil
21
37
  end
22
38
 
39
+ def with_sequence_generator(seed:)
40
+ previous = ::Thread.current[:vident_number_sequence_generator]
41
+ set_current_sequence_generator(seed: seed)
42
+ yield
43
+ ensure
44
+ ::Thread.current[:vident_number_sequence_generator] = previous
45
+ end
46
+
23
47
  def next_id_in_sequence
24
- generator = ::Thread.current[:vident_number_sequence_generator]
25
- # When no generator exists, use a random value. This means we loose the stability of the ID sequence but
26
- # at least generate unique IDs for the current render.
27
- return Random.hex(16) unless generator
28
- generator.next.join("-")
48
+ unless @strategy
49
+ raise StrategyNotConfiguredError,
50
+ "Vident::StableId.strategy is not configured. Run " \
51
+ "`bin/rails generate vident:install`, or set it manually in an " \
52
+ "initializer (e.g. `Vident::StableId.strategy = Vident::StableId::STRICT`)."
53
+ end
54
+ @strategy.call(::Thread.current[:vident_number_sequence_generator])
29
55
  end
30
56
 
31
57
  private
32
58
 
33
- def id_sequence_generator
34
- number_generator = Random.new(42)
59
+ def id_sequence_generator(seed)
60
+ raise ArgumentError, "seed: cannot be nil" if seed.nil?
61
+ number_generator = Random.new(coerce_seed(seed))
35
62
  Enumerator.produce { number_generator.hex(16) }.with_index
36
63
  end
64
+
65
+ def coerce_seed(seed)
66
+ Digest::MD5.hexdigest(seed.to_s).to_i(16)
67
+ end
37
68
  end
38
69
  end
39
70
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ # Vident's internal naming conventions for per-primitive wiring — the
6
+ # `add_stimulus_<plural>` mutator method, the `@stimulus_<plural>` prop
7
+ # ivar, and the `@stimulus_<plural>_collection` parsed-collection ivar.
8
+ # Mixed in by the consumers that need these helpers. Kept off `Primitive`
9
+ # so the primitive stays a clean domain value object and doesn't carry
10
+ # the implementation details of its consumers.
11
+ module Naming
12
+ def mutator_method(primitive) = :"add_stimulus_#{primitive.name}"
13
+
14
+ def prop_ivar(primitive) = :"@stimulus_#{primitive.name}"
15
+
16
+ def collection_ivar(primitive) = :"@stimulus_#{primitive.name}_collection"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ # A Stimulus primitive kind: name, plus the Value/Collection classes that
6
+ # back it. Two concrete subclasses distinguish how the primitive behaves
7
+ # when a Hash is passed to the plural parser:
8
+ #
9
+ # - `Keyed` — `{a: 1, b: 2}` expands to one value object per pair.
10
+ # Used for values / params / classes / outlets.
11
+ # - `Positional` — `{...}` is a single-arg descriptor (e.g. Action's
12
+ # `{event:, method:, ...}` short form).
13
+ # Used for controllers / actions / targets.
14
+ class Primitive < ::Data.define(:name, :value_class, :collection_class)
15
+ # Short forms. `name` (Data field) is the plural — `:values`; `plural`
16
+ # is an alias for symmetry with `singular`.
17
+ alias_method :plural, :name
18
+ def singular = name.to_s.singularize.to_sym
19
+
20
+ # The primitive's key in Vident's attribute namespace. Used both as the
21
+ # parser method name (`def stimulus_values(...)`) and as the hash key
22
+ # in DSL attrs / component props / `root_element_attributes` — the same
23
+ # Symbol serves all three roles.
24
+ def key = :"stimulus_#{name}"
25
+ def singular_key = :"stimulus_#{singular}"
26
+
27
+ def keyed? = raise NotImplementedError
28
+ end
29
+
30
+ class KeyedPrimitive < Primitive
31
+ def keyed? = true
32
+ end
33
+
34
+ class PositionalPrimitive < Primitive
35
+ def keyed? = false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident
4
+ module Stimulus
5
+ # Registry of primitive kinds. Add an entry (paired with a Value/Collection
6
+ # class pair) to extend; plural parsers, mutators, and the data-attribute
7
+ # builder pick it up. Array order = data attribute emission order.
8
+ PRIMITIVES = [
9
+ PositionalPrimitive.new(:controllers, StimulusController, StimulusControllerCollection),
10
+ PositionalPrimitive.new(:actions, StimulusAction, StimulusActionCollection),
11
+ PositionalPrimitive.new(:targets, StimulusTarget, StimulusTargetCollection),
12
+ KeyedPrimitive.new(:outlets, StimulusOutlet, StimulusOutletCollection),
13
+ KeyedPrimitive.new(:values, StimulusValue, StimulusValueCollection),
14
+ KeyedPrimitive.new(:params, StimulusParam, StimulusParamCollection),
15
+ KeyedPrimitive.new(:classes, StimulusClass, StimulusClassCollection)
16
+ ].freeze
17
+
18
+ PRIMITIVES_BY_NAME = PRIMITIVES.to_h { |primitive| [primitive.name, primitive] }.freeze
19
+
20
+ class << self
21
+ def primitive(name)
22
+ PRIMITIVES_BY_NAME[name] or
23
+ raise ArgumentError, "Unknown stimulus primitive #{name.inspect}; valid: #{PRIMITIVES_BY_NAME.keys.inspect}"
24
+ end
25
+
26
+ def each(&block) = PRIMITIVES.each(&block)
27
+
28
+ def names = PRIMITIVES.map(&:name)
29
+ end
30
+ end
31
+ end
@@ -2,24 +2,48 @@
2
2
 
3
3
  module Vident
4
4
  class StimulusAction < StimulusAttributeBase
5
- attr_reader :event, :controller, :action
5
+ # https://stimulus.hotwired.dev/reference/actions#options
6
+ VALID_OPTIONS = [:once, :prevent, :stop, :passive, :"!passive", :capture, :self].freeze
6
7
 
7
- def to_s
8
- if @event
9
- "#{@event}->#{@controller}##{@action}"
10
- else
11
- "#{@controller}##{@action}"
12
- end
8
+ # Typed descriptor for modifiers (`:once`/`:prevent`/etc., keyboard filter,
9
+ # `@window`) that the plain Array form can't express. Hash input to the
10
+ # parsers (`{event:, method:, ...}`) is desugared into one of these.
11
+ class Descriptor < ::Literal::Data
12
+ prop :method, _Union(Symbol, String)
13
+ prop :event, _Nilable(_Union(Symbol, String)), default: nil
14
+ prop :controller, _Nilable(String), default: nil
15
+ prop :options, _Array(Symbol), default: -> { [] }
16
+ prop :keyboard, _Nilable(String), default: nil
17
+ prop :window, _Boolean, default: false
13
18
  end
14
19
 
15
- def data_attribute_name
16
- "action"
20
+ attr_reader :event, :controller, :action, :options, :keyboard, :window
21
+
22
+ def initialize(*args, implied_controller: nil)
23
+ @options = []
24
+ @keyboard = nil
25
+ @window = false
26
+ super
17
27
  end
18
28
 
19
- def data_attribute_value
20
- to_s
29
+ def to_s
30
+ head =
31
+ if @event
32
+ ev = @event.to_s
33
+ ev = "#{ev}.#{@keyboard}" if @keyboard
34
+ ev = "#{ev}#{@options.map { |o| ":#{o}" }.join}" if @options.any?
35
+ ev = "#{ev}@window" if @window
36
+ "#{ev}->"
37
+ else
38
+ ""
39
+ end
40
+ "#{head}#{@controller}##{@action}"
21
41
  end
22
42
 
43
+ def data_attribute_name = "action"
44
+
45
+ def data_attribute_value = to_s
46
+
23
47
  private
24
48
 
25
49
  def parse_arguments(*args)
@@ -38,27 +62,25 @@ module Vident
38
62
  end
39
63
 
40
64
  def parse_single_argument(arg)
41
- if arg.is_a?(Symbol)
42
- # 1 symbol arg, name of method on implied controller
65
+ case arg
66
+ when Descriptor then apply_descriptor(arg)
67
+ when Hash then apply_descriptor(Descriptor.new(**arg))
68
+ when Symbol
43
69
  @event = nil
44
70
  @controller = implied_controller_name
45
71
  @action = js_name(arg)
46
- elsif arg.is_a?(String)
47
- # 1 string arg, fully qualified action - parse it
48
- parse_qualified_action_string(arg)
49
- else
50
- raise ArgumentError, "Invalid 'action' argument types (1): #{arg.class}"
72
+ when String then parse_qualified_action_string(arg)
73
+ else raise ArgumentError, "Invalid 'action' argument type (1): #{arg.class}"
51
74
  end
52
75
  end
53
76
 
77
+ # (:event, :method) or ("controller/path", :method)
54
78
  def parse_two_arguments(part1, part2)
55
79
  if part1.is_a?(Symbol) && part2.is_a?(Symbol)
56
- # 2 symbol args = event + action
57
80
  @event = part1.to_s
58
81
  @controller = implied_controller_name
59
82
  @action = js_name(part2)
60
83
  elsif part1.is_a?(String) && part2.is_a?(Symbol)
61
- # 1 string arg, 1 symbol = controller + action
62
84
  @event = nil
63
85
  @controller = stimulize_path(part1)
64
86
  @action = js_name(part2)
@@ -67,9 +89,9 @@ module Vident
67
89
  end
68
90
  end
69
91
 
92
+ # (:event, "controller/path", :method)
70
93
  def parse_three_arguments(part1, part2, part3)
71
94
  if part1.is_a?(Symbol) && part2.is_a?(String) && part3.is_a?(Symbol)
72
- # 1 symbol, 1 string, 1 symbol = event + controller + action
73
95
  @event = part1.to_s
74
96
  @controller = stimulize_path(part2)
75
97
  @action = js_name(part3)
@@ -78,16 +100,29 @@ module Vident
78
100
  end
79
101
  end
80
102
 
103
+ def apply_descriptor(d)
104
+ invalid = d.options - VALID_OPTIONS
105
+ unless invalid.empty?
106
+ raise ArgumentError,
107
+ "Invalid action option(s) #{invalid.inspect}. Valid: #{VALID_OPTIONS.inspect}"
108
+ end
109
+
110
+ @event = d.event&.to_s
111
+ @controller = d.controller ? stimulize_path(d.controller) : implied_controller_name
112
+ @action = d.method.is_a?(Symbol) ? js_name(d.method) : d.method.to_s
113
+ @options = d.options
114
+ @keyboard = d.keyboard
115
+ @window = d.window
116
+ end
117
+
81
118
  def parse_qualified_action_string(action_string)
82
119
  if action_string.include?("->")
83
- # Has event: "click->controller#action"
84
120
  event_part, controller_action = action_string.split("->", 2)
85
121
  @event = event_part
86
122
  controller_part, action_part = controller_action.split("#", 2)
87
123
  @controller = controller_part
88
124
  @action = action_part
89
125
  else
90
- # No event: "controller#action"
91
126
  @event = nil
92
127
  controller_part, action_part = action_string.split("#", 2)
93
128
  @controller = controller_part
@@ -2,8 +2,20 @@
2
2
 
3
3
  require "active_support/core_ext/string/inflections"
4
4
 
5
+ require "json"
6
+
5
7
  module Vident
6
8
  class StimulusAttributeBase
9
+ # `"admin/users"` → `"admin--users"`; accepts Symbol or String.
10
+ def self.stimulize_path(path)
11
+ path.to_s.split("/").map(&:dasherize).join("--")
12
+ end
13
+
14
+ # `:my_thing` → `"myThing"`
15
+ def self.js_name(name)
16
+ name.to_s.camelize(:lower)
17
+ end
18
+
7
19
  attr_reader :implied_controller
8
20
 
9
21
  def initialize(*args, implied_controller: nil)
@@ -11,27 +23,17 @@ module Vident
11
23
  parse_arguments(*args)
12
24
  end
13
25
 
14
- def inspect
15
- "#<#{self.class.name} #{to_h}>"
16
- end
26
+ def inspect = "#<#{self.class.name} #{to_h}>"
17
27
 
18
- def to_s
19
- raise NoMethodError, "Subclasses must implement to_s"
20
- end
28
+ def to_s = raise(NoMethodError, "Subclasses must implement to_s")
21
29
 
22
- def to_h
23
- {data_attribute_name => data_attribute_value}
24
- end
30
+ def to_h = {data_attribute_name => data_attribute_value}
25
31
 
26
32
  alias_method :to_hash, :to_h
27
33
 
28
- def data_attribute_name
29
- raise NoMethodError, "Subclasses must implement data_attribute_name"
30
- end
34
+ def data_attribute_name = raise(NoMethodError, "Subclasses must implement data_attribute_name")
31
35
 
32
- def data_attribute_value
33
- raise NoMethodError, "Subclasses must implement data_attribute_value"
34
- end
36
+ def data_attribute_value = raise(NoMethodError, "Subclasses must implement data_attribute_value")
35
37
 
36
38
  def implied_controller_path
37
39
  raise ArgumentError, "implied_controller is required to get implied controller path" unless implied_controller
@@ -45,17 +47,19 @@ module Vident
45
47
 
46
48
  private
47
49
 
48
- # Convert a file path to a stimulus controller name
49
- def stimulize_path(path)
50
- path.split("/").map { |p| p.to_s.dasherize }.join("--")
51
- end
50
+ def stimulize_path(path) = self.class.stimulize_path(path)
52
51
 
53
- # Convert a Ruby 'snake case' string to a JavaScript camel case strings
54
- def js_name(name)
55
- name.to_s.camelize(:lower)
52
+ def js_name(name) = self.class.js_name(name)
53
+
54
+ # Arrays/Hashes serialise as JSON; everything else via `to_s` (which is how
55
+ # `Vident::StimulusNull` emits the literal `"null"`).
56
+ def serialize_value(value)
57
+ case value
58
+ when Array, Hash then value.to_json
59
+ else value.to_s
60
+ end
56
61
  end
57
62
 
58
- # Subclasses must implement this method
59
63
  def parse_arguments(*args)
60
64
  raise NotImplementedError, "Subclasses must implement parse_arguments"
61
65
  end