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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module Vident2
6
+ module Internals
7
+ # @api private
8
+ # Pure: `Plan -> Hash{Symbol => String}` of `data-*` fragments.
9
+ # Delegates per-kind combining (space-join, grouped-by-controller,
10
+ # one-per-key) to each value class's `.to_data_hash`.
11
+ module AttributeWriter
12
+ module_function
13
+
14
+ def call(plan)
15
+ Registry::KINDS.each_with_object({}) do |kind, acc|
16
+ fragment = kind.value_class.to_data_hash(plan.public_send(kind.name))
17
+ acc.merge!(fragment)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Vident2
6
+ module Internals
7
+ # @api private
8
+ # Root-element class-list builder. Implements the 6-tier precedence
9
+ # cascade plus optional TailwindMerger dedup.
10
+ #
11
+ # Tiers (render order, left -> right):
12
+ # 1. component_name — always first.
13
+ # 2-5. Priority cascade (only the highest non-nil wins):
14
+ # root_element_classes (instance override) <
15
+ # root_element_attributes[:classes] <
16
+ # root_element(class:) from template <
17
+ # html_options[:class] from prop
18
+ # 6. classes: prop — ALWAYS appended, even when tier 5 is present.
19
+ #
20
+ # Plus: per-kind StimulusClassMap entries whose name is in
21
+ # `stimulus_class_names` are appended as CSS. Tailwind merge runs last
22
+ # if the merger is passed.
23
+ module ClassListBuilder
24
+ CLASSNAME_SEPARATOR = " "
25
+
26
+ module_function
27
+
28
+ def call(
29
+ component_name: nil,
30
+ root_element_classes: nil,
31
+ root_element_attributes_classes: nil,
32
+ root_element_html_class: nil,
33
+ html_options_class: nil,
34
+ classes_prop: nil,
35
+ stimulus_classes: nil,
36
+ stimulus_class_names: nil,
37
+ tailwind_merger: nil
38
+ )
39
+ parts = []
40
+ parts << component_name if component_name
41
+
42
+ # Priority cascade: top-down, first non-nil wins.
43
+ if html_options_class
44
+ parts.concat(Array.wrap(html_options_class))
45
+ elsif root_element_html_class
46
+ parts.concat(Array.wrap(root_element_html_class))
47
+ elsif root_element_attributes_classes
48
+ parts.concat(Array.wrap(root_element_attributes_classes))
49
+ elsif root_element_classes
50
+ parts.concat(Array.wrap(root_element_classes))
51
+ end
52
+
53
+ # `classes:` prop: always appended, even when something in the
54
+ # cascade already contributed.
55
+ parts.concat(Array.wrap(classes_prop)) if classes_prop
56
+
57
+ parts.compact!
58
+
59
+ if stimulus_classes && stimulus_class_names && !stimulus_class_names.empty?
60
+ parts.concat(stimulus_class_css(stimulus_classes, stimulus_class_names))
61
+ end
62
+
63
+ flattened = parts.flat_map { |s| s.to_s.split(/\s+/) }.reject(&:empty?)
64
+ deduped = flattened.uniq
65
+ return nil if deduped.empty?
66
+
67
+ joined = deduped.join(CLASSNAME_SEPARATOR)
68
+ tailwind_merger ? tailwind_merger.merge(joined) : joined
69
+ end
70
+
71
+ # Pick ClassMap entries whose `name` matches any of the requested
72
+ # Symbols/Strings (dasherized to match the ClassMap's canonical form).
73
+ def stimulus_class_css(class_maps, names)
74
+ names_set = names.map { |n| n.to_s.dasherize }.to_set
75
+ class_maps.select { |cm| names_set.include?(cm.name) }.map(&:css)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vident2
4
+ module Internals
5
+ # @api private
6
+ # One unresolved DSL entry. `args` is the raw argument tuple passed
7
+ # to the DSL primitive; the Resolver parses it into a Stimulus value
8
+ # object at instance init. `when_proc` (optional) is a `-> { ... }`
9
+ # filter evaluated in the component binding; `meta` is a free-form
10
+ # Hash for options like `from_prop: true` the parser needs to see.
11
+ Declaration = Data.define(:args, :when_proc, :meta) do
12
+ def self.of(*args, when_proc: nil, **meta)
13
+ new(args: args.freeze, when_proc: when_proc, meta: meta.freeze)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "declaration"
4
+
5
+ module Vident2
6
+ module Internals
7
+ # @api private
8
+ # Frozen per-class aggregate of what `stimulus do ... end` declared.
9
+ # One field per kind (plural Registry name) plus `values_from_props`.
10
+ # Entries stay as raw `Declaration` records — the Resolver parses
11
+ # them into `Stimulus::*` value objects at instance init, not here.
12
+ #
13
+ # Keyed kinds (values, params, class_maps, outlets) use `(key, entry)`
14
+ # pairs to let a later block's same-key entry replace an earlier one.
15
+ # Positional kinds (controllers, actions, targets) are flat arrays;
16
+ # later blocks append.
17
+ Declarations = Data.define(
18
+ :controllers,
19
+ :actions,
20
+ :targets,
21
+ :outlets,
22
+ :values,
23
+ :params,
24
+ :class_maps,
25
+ :values_from_props
26
+ ) do
27
+ EMPTY_ARRAY = [].freeze
28
+
29
+ def self.empty = @empty ||= new(
30
+ controllers: EMPTY_ARRAY,
31
+ actions: EMPTY_ARRAY,
32
+ targets: EMPTY_ARRAY,
33
+ outlets: EMPTY_ARRAY,
34
+ values: EMPTY_ARRAY,
35
+ params: EMPTY_ARRAY,
36
+ class_maps: EMPTY_ARRAY,
37
+ values_from_props: EMPTY_ARRAY
38
+ ).freeze
39
+
40
+ def any?
41
+ !controllers.empty? || !actions.empty? || !targets.empty? ||
42
+ !outlets.empty? || !values.empty? || !params.empty? ||
43
+ !class_maps.empty? || !values_from_props.empty?
44
+ end
45
+
46
+ # Merge two Declarations, treating `self` as parent and `other` as
47
+ # child. Positional kinds concat (parent first, then child).
48
+ # Keyed kinds last-wins on matching key.
49
+ def merge(other)
50
+ self.class.new(
51
+ controllers: concat_positional(controllers, other.controllers),
52
+ actions: concat_positional(actions, other.actions),
53
+ targets: concat_positional(targets, other.targets),
54
+ outlets: merge_keyed(outlets, other.outlets),
55
+ values: merge_keyed(values, other.values),
56
+ params: merge_keyed(params, other.params),
57
+ class_maps: merge_keyed(class_maps, other.class_maps),
58
+ values_from_props: (values_from_props + other.values_from_props).uniq.freeze
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ def concat_positional(a, b) = (a + b).freeze
65
+
66
+ # Keyed entries are `[key, Declaration]` tuples; last write on a
67
+ # given key wins, insertion order otherwise preserved.
68
+ def merge_keyed(a, b)
69
+ merged = {}
70
+ a.each { |(k, d)| merged[k] = d }
71
+ b.each { |(k, d)| merged[k] = d }
72
+ merged.to_a.freeze
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "plan"
5
+
6
+ module Vident2
7
+ module Internals
8
+ # @api private
9
+ # Per-instance mutable working copy. Seven Arrays, one per Registry
10
+ # kind. `add_<kind>(value_or_values)` mutators are the single sanctioned
11
+ # seam for cross-instance mutation (outlet-host pattern) and for
12
+ # `add_stimulus_*` calls from `after_component_initialize`.
13
+ #
14
+ # After `seal!` the Draft is closed — any further `add_*` raises
15
+ # `Vident2::StateError`. The sealed Plan is a frozen Data.define snapshot.
16
+ class Draft
17
+ def initialize(**collections)
18
+ @collections = Registry.names.to_h { |name| [name, []] }
19
+ collections.each { |k, v| @collections[k] = v.dup if @collections.key?(k) }
20
+ @sealed = false
21
+ end
22
+
23
+ Registry.each do |kind|
24
+ # reader
25
+ define_method(kind.name) { @collections[kind.name] }
26
+
27
+ # mutator: one call = one logical add. Array input concats all
28
+ # elements as pre-parsed values; a single non-Array value appends
29
+ # as one entry.
30
+ define_method(:"add_#{kind.name}") do |value_or_values|
31
+ raise_if_sealed!
32
+ Array(value_or_values).each { |v| @collections[kind.name] << v }
33
+ self
34
+ end
35
+ end
36
+
37
+ def sealed? = @sealed
38
+
39
+ # Freeze the working copy and snapshot as a frozen Plan. Idempotent:
40
+ # subsequent calls return the memoised Plan.
41
+ def seal!
42
+ return @plan if @sealed
43
+ @sealed = true
44
+ @collections.each_value(&:freeze)
45
+ @collections.freeze
46
+ @plan = Plan.new(**@collections)
47
+ end
48
+
49
+ def plan = seal!
50
+
51
+ private
52
+
53
+ def raise_if_sealed!
54
+ return unless @sealed
55
+ raise ::Vident2::StateError,
56
+ "cannot modify stimulus attributes after rendering has begun"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "declaration"
4
+ require_relative "declarations"
5
+ require_relative "action_builder"
6
+ require_relative "target_builder"
7
+
8
+ module Vident2
9
+ module Internals
10
+ # @api private
11
+ # Block receiver for `stimulus do ... end`. Records each primitive
12
+ # call as one or more `Declaration` raw entries; `finalize` folds
13
+ # them into a frozen `Declarations` aggregate.
14
+ #
15
+ # Parsing into `Stimulus::*` value objects is deferred to the
16
+ # Resolver — this class stores only raw argument tuples.
17
+ class DSL
18
+ attr_reader :caller_location
19
+
20
+ def initialize(caller_location: nil)
21
+ @caller_location = caller_location
22
+ @controllers = []
23
+ @actions = []
24
+ @targets = []
25
+ @outlets = []
26
+ @values = []
27
+ @params = []
28
+ @class_maps = []
29
+ @values_from_props = []
30
+ end
31
+
32
+ # ---- plural (kwargs) forms --------------------------------------
33
+
34
+ # Each arg becomes one controller entry. An Array arg is splatted
35
+ # into positional args for a single controller (e.g. a tuple
36
+ # `[path, {as: :alias}]`); anything else is treated as a path.
37
+ def controllers(*args)
38
+ args.each do |arg|
39
+ case arg
40
+ in Array
41
+ controller(*arg)
42
+ else
43
+ controller(arg)
44
+ end
45
+ end
46
+ self
47
+ end
48
+
49
+ # Array in the plural form splats into the singular parser (matching
50
+ # V1's plural→singular forwarding) so `actions [:click, :handle]`
51
+ # records a single Action entry with event+method rather than two
52
+ # separate Actions.
53
+ def actions(*names)
54
+ names.each do |name|
55
+ case name
56
+ in Array
57
+ action(*name)
58
+ else
59
+ action(name)
60
+ end
61
+ end
62
+ self
63
+ end
64
+
65
+ def targets(*names)
66
+ names.each do |name|
67
+ case name
68
+ in Array
69
+ target(*name)
70
+ else
71
+ target(name)
72
+ end
73
+ end
74
+ self
75
+ end
76
+
77
+ def values(**hash)
78
+ hash.each { |k, v| record_keyed(@values, k, v) }
79
+ self
80
+ end
81
+
82
+ def params(**hash)
83
+ hash.each { |k, v| record_keyed(@params, k, v) }
84
+ self
85
+ end
86
+
87
+ def classes(**hash)
88
+ hash.each { |k, v| record_keyed(@class_maps, k, v) }
89
+ self
90
+ end
91
+
92
+ # Outlets accept a positional Hash (for keys like `"admin--users"`
93
+ # that can't be a Ruby kwarg) plus kwargs. Order: positional first,
94
+ # kwargs after — last-write wins on duplicates per the keyed merge
95
+ # rule.
96
+ def outlets(positional = nil, **hash)
97
+ if positional.is_a?(Hash)
98
+ positional.each { |k, v| record_keyed(@outlets, k, v) }
99
+ elsif !positional.nil?
100
+ raise ArgumentError, "outlets: positional arg must be a Hash, got #{positional.class}"
101
+ end
102
+ hash.each { |k, v| record_keyed(@outlets, k, v) }
103
+ self
104
+ end
105
+
106
+ def values_from_props(*names)
107
+ @values_from_props.concat(names.map(&:to_sym))
108
+ self
109
+ end
110
+
111
+ # ---- singular forms --------------------------------------------
112
+
113
+ # Optional `as: :alias` captured in meta for the Resolver.
114
+ def controller(*args, **meta)
115
+ @controllers << Declaration.of(*args, **meta)
116
+ self
117
+ end
118
+
119
+ # Returns an `ActionBuilder` so users can fluent-chain
120
+ # `.on(:event).modifier(:prevent).keyboard("ctrl+s").window.when { ... }`.
121
+ # If no chain methods are called, the raw args pass through unchanged.
122
+ def action(*args)
123
+ builder = ActionBuilder.new(*args)
124
+ @actions << builder
125
+ builder
126
+ end
127
+
128
+ # Returns a `TargetBuilder` so users can chain `.when { ... }` for
129
+ # conditional inclusion.
130
+ def target(*args)
131
+ builder = TargetBuilder.new(*args)
132
+ @targets << builder
133
+ builder
134
+ end
135
+
136
+ # `value(:url, "x")`, `value(:url, -> { ... })`,
137
+ # `value(:count, static: 0)`, `value(:clicked_count, from_prop: true)`.
138
+ def value(name, *args, **meta)
139
+ entry = [name, Declaration.of(*args, **meta)]
140
+ replace_or_append(@values, entry)
141
+ self
142
+ end
143
+
144
+ def param(name, *args, **meta)
145
+ entry = [name, Declaration.of(*args, **meta)]
146
+ replace_or_append(@params, entry)
147
+ self
148
+ end
149
+
150
+ def outlet(name, *args, **meta)
151
+ entry = [name, Declaration.of(*args, **meta)]
152
+ replace_or_append(@outlets, entry)
153
+ self
154
+ end
155
+
156
+ def class_map(name, *args, **meta)
157
+ entry = [name, Declaration.of(*args, **meta)]
158
+ replace_or_append(@class_maps, entry)
159
+ self
160
+ end
161
+
162
+ # ---- folding ----------------------------------------------------
163
+
164
+ # Returns a frozen Declarations snapshot of what this block
165
+ # received. Called once the block finishes executing.
166
+ def to_declarations
167
+ Declarations.new(
168
+ controllers: @controllers.dup.freeze,
169
+ actions: @actions.map(&:to_declaration).freeze,
170
+ targets: @targets.map(&:to_declaration).freeze,
171
+ outlets: @outlets.dup.freeze,
172
+ values: @values.dup.freeze,
173
+ params: @params.dup.freeze,
174
+ class_maps: @class_maps.dup.freeze,
175
+ values_from_props: @values_from_props.dup.freeze
176
+ ).freeze
177
+ end
178
+
179
+ private
180
+
181
+ def record_keyed(bucket, key, value)
182
+ entry = [key, Declaration.of(value)]
183
+ replace_or_append(bucket, entry)
184
+ end
185
+
186
+ # Last-write wins on matching key, insertion order otherwise.
187
+ def replace_or_append(bucket, entry)
188
+ key = entry.first
189
+ idx = bucket.index { |(k, _)| k == key }
190
+ if idx
191
+ bucket[idx] = entry
192
+ else
193
+ bucket << entry
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module Vident2
6
+ module Internals
7
+ # @api private
8
+ # Frozen snapshot produced by `Draft#seal!`. One field per Registry
9
+ # kind, each an Array<Stimulus::*>.
10
+ Plan = Data.define(*Registry.names)
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../stimulus/controller"
4
+ require_relative "../stimulus/action"
5
+ require_relative "../stimulus/target"
6
+ require_relative "../stimulus/outlet"
7
+ require_relative "../stimulus/value"
8
+ require_relative "../stimulus/param"
9
+ require_relative "../stimulus/class_map"
10
+
11
+ module Vident2
12
+ # @api private — consumed by the DSL, Resolver, Draft, Plan,
13
+ # AttributeWriter, and Capabilities::StimulusMutation. Not a public
14
+ # extension surface; extensions monkeypatch at their own risk.
15
+ module Internals
16
+ module Registry
17
+ # Per-kind metadata. `name` is the canonical internal key;
18
+ # `plural_name` / `singular_name` drive DSL method names (e.g.
19
+ # `stimulus_classes` / `stimulus_class` for `class_maps`); `keyed`
20
+ # distinguishes hash-shaped kinds (values, params, class_maps,
21
+ # outlets) from positional ones (controllers, actions, targets).
22
+ Kind = Data.define(:name, :plural_name, :singular_name, :value_class, :keyed)
23
+
24
+ KINDS = [
25
+ Kind.new(:controllers, :controllers, :controller, Vident2::Stimulus::Controller, false),
26
+ Kind.new(:actions, :actions, :action, Vident2::Stimulus::Action, false),
27
+ Kind.new(:targets, :targets, :target, Vident2::Stimulus::Target, false),
28
+ Kind.new(:outlets, :outlets, :outlet, Vident2::Stimulus::Outlet, true),
29
+ Kind.new(:values, :values, :value, Vident2::Stimulus::Value, true),
30
+ Kind.new(:params, :params, :param, Vident2::Stimulus::Param, true),
31
+ Kind.new(:class_maps, :classes, :class, Vident2::Stimulus::ClassMap, true)
32
+ ].freeze
33
+
34
+ BY_NAME = KINDS.to_h { |k| [k.name, k] }.freeze
35
+
36
+ def self.fetch(name) = BY_NAME.fetch(name)
37
+ def self.each(&block) = KINDS.each(&block)
38
+ def self.names = BY_NAME.keys
39
+ end
40
+ end
41
+ end