attrify 0.4.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attrify
4
+ module Helpers
5
+ def compute_attributes(hash)
6
+ results = {}
7
+
8
+ hash.each do |key, operations|
9
+ if operations.is_a?(Hash)
10
+ results[key] = compute_attributes(operations)
11
+ elsif operations.is_a?(Array)
12
+ current_value = []
13
+
14
+ # Process each operation in order
15
+ operations.each do |operation_hash|
16
+ operation_hash.each do |operation, value|
17
+ current_value = execute_operation(operation.to_sym, current_value, value)
18
+ end
19
+ end
20
+
21
+ # Flatten array and convert to string if not a hash
22
+ results[key] = current_value.join(" ")
23
+ else
24
+ results[key] = operations
25
+ end
26
+ end
27
+
28
+ results
29
+ end
30
+
31
+ def execute_operation(operation, current_value, value)
32
+ case operation
33
+ when :append
34
+ current_value + value
35
+ when :prepend
36
+ value + current_value
37
+ when :remove
38
+ current_value - value
39
+ when :set
40
+ value
41
+ else
42
+ current_value
43
+ end
44
+ end
45
+
46
+ def deep_merge_hashes!(hash1, hash2)
47
+ hash1.merge!(hash2) do |key, oldval, newval|
48
+ if oldval.is_a?(Hash)
49
+ deep_merge_hashes(oldval, newval)
50
+ elsif oldval.is_a?(Array)
51
+ oldval + newval # Concatenate arrays
52
+ else
53
+ newval # In case of conflicting types or non-container types, prefer newval
54
+ end
55
+ end
56
+ end
57
+
58
+ def deep_merge_hashes(hash1, hash2)
59
+ hash1.merge(hash2) do |key, oldval, newval|
60
+ if oldval.is_a?(Hash)
61
+ deep_merge_hashes(oldval, newval)
62
+ elsif oldval.is_a?(Array)
63
+ oldval + newval # Concatenate arrays
64
+ else
65
+ newval
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "attrify/parser"
5
+
6
+ module Attrify
7
+ class OperationSet
8
+ include ActionView::Helpers::TagHelper
9
+ include Helpers
10
+
11
+ attr_reader :operations
12
+ attr_reader :has_procs
13
+
14
+ def initialize(operations, has_procs = false)
15
+ @has_procs = has_procs
16
+ @operations = operations
17
+ end
18
+
19
+ def to_html
20
+ tag.attributes(@result)
21
+ end
22
+
23
+ def to_tags
24
+ to_hash
25
+ end
26
+
27
+ def dig(*keys)
28
+ result = @result.dig(*keys)
29
+
30
+ if result.present?
31
+ if result.has_key?(:attributes)
32
+ result[:attributes]
33
+ end
34
+ end
35
+ end
36
+
37
+ def values_for(instance:, keys:)
38
+ # if @has_procs
39
+
40
+ @operations = run_procs_on(@operations, instance)
41
+ @result = cache_result(@operations)[:value]
42
+ @result = @result.dig(*keys)
43
+ if @result.present?
44
+ if @result.has_key?(:attributes)
45
+ @result = @result[:attributes]
46
+ end
47
+ end
48
+ self
49
+ # else
50
+ # self
51
+ # end
52
+ end
53
+
54
+ def to_hash
55
+ @result
56
+ end
57
+
58
+ # Retrieve value by key
59
+ def [](key)
60
+ @result[key]
61
+ end
62
+
63
+ # Set value by key
64
+ def []=(key, value)
65
+ @result[key] = value
66
+ end
67
+
68
+ # Iterate like a hash
69
+ def each(&)
70
+ @result.each(&)
71
+ end
72
+
73
+ # Return all keys
74
+ def keys
75
+ @result.keys
76
+ end
77
+
78
+ # Return all values
79
+ def values
80
+ @result
81
+ end
82
+
83
+ def to_s
84
+ to_html
85
+ end
86
+
87
+ private
88
+
89
+ def cache_result(hash, current_root = nil)
90
+ results = {}
91
+ has_procs = false
92
+
93
+ hash.each do |key, operations|
94
+ # Set current_root if it's not already set (top-level keys)
95
+ current_root = key if current_root.nil?
96
+
97
+ if operations.is_a?(Hash)
98
+ # Recursively process the nested hash
99
+ result = cache_result(operations, current_root)
100
+ results[key] = result[:value]
101
+ has_procs ||= result[:has_procs]
102
+ elsif operations.is_a?(Array)
103
+ current_value = []
104
+ # Determine if we can cache the value
105
+ can_cache_value = operations.any? { |operation| operation.key?(:set) } ||
106
+ current_root == :main
107
+
108
+ # Process each operation in order
109
+ operations.each do |operation_hash|
110
+ operation_hash.each do |operation, value|
111
+ # Ensure value is an array
112
+ value_array = Array(value)
113
+ has_procs ||= value_array.any? { |c| c.is_a?(Proc) }
114
+ if can_cache_value
115
+ current_value = execute_operation(operation.to_sym, current_value, value_array)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Set the result based on whether we can cache the value
121
+ results[key] = if can_cache_value
122
+ current_value.join(" ")
123
+ else
124
+ operations
125
+ end
126
+ else
127
+ has_procs ||= operations.is_a?(Proc)
128
+ results[key] = operations
129
+ end
130
+ end
131
+
132
+ {value: results, has_procs: has_procs}
133
+ end
134
+
135
+ def cache_resultss(hash)
136
+ results = {}
137
+ has_procs = false
138
+
139
+ hash.each do |key, operations|
140
+ if operations.is_a?(Hash)
141
+ result = cache_result(operations)
142
+ results[key] = result[:value]
143
+ has_procs = true if result[:has_procs]
144
+ elsif operations.is_a?(Array)
145
+ current_value = []
146
+ # If any operation is a SET operation, then we can simply perform all operations now
147
+ can_cache_value = operations.any? { |operation| operation.key?(:set) } || key == :main
148
+
149
+ # Process each operation in order
150
+ operations.each do |operation_hash|
151
+ operation_hash.each do |operation, value|
152
+ has_procs = true if value.any? { |c| c.is_a?(Proc) }
153
+ if can_cache_value
154
+ current_value = execute_operation(operation.to_sym, current_value, value)
155
+ end
156
+ end
157
+ end
158
+
159
+ # Flatten array and convert to string if not a hash
160
+ results[key] = if can_cache_value
161
+ current_value.join(" ")
162
+ else
163
+ operations
164
+ end
165
+ else
166
+ has_procs = true if operations.is_a?(Proc)
167
+ results[key] = operations
168
+ end
169
+ end
170
+
171
+ {value: results, has_procs: has_procs}
172
+ end
173
+
174
+ def merge_arrays(hash)
175
+ hash.transform_values do |value|
176
+ case value
177
+ when Hash
178
+ merge_arrays(value) # Recursively process nested hashes
179
+ when Array
180
+ if value.all? { |v| v.is_a?(String) }
181
+ value.join(" ") # Join array of strings into a single string
182
+ else
183
+ value.map { |v| v.is_a?(Hash) ? merge_arrays(v) : v }
184
+ end
185
+ else
186
+ value # Return the value as is if it's neither a Hash nor an Array
187
+ end
188
+ end
189
+ end
190
+
191
+ # Recursively traverse a hash and run any procs
192
+ def run_procs_on(hash, instance)
193
+ hash.each_with_object({}) do |(key, value), processed_hash|
194
+ processed_hash[key] = if value.is_a?(Hash)
195
+ # Recursively handle nested hashes
196
+ run_procs_on(value, instance)
197
+ elsif value.is_a?(Array)
198
+ # Process arrays, replacing any procs
199
+ value.map do |element|
200
+ if element.is_a?(Proc)
201
+ # If it's a proc, execute it with the instance
202
+ instance.instance_exec(&element)
203
+ elsif element.is_a?(Hash)
204
+ # Recursively handle nested hashes
205
+ run_procs_on(element, instance)
206
+ else
207
+ element
208
+ end
209
+ end
210
+ elsif value.is_a?(Proc)
211
+ # If it's a proc, execute it with the instance
212
+ instance.instance_exec(&value)
213
+ else
214
+ # If it's not a proc or a hash, keep it as is
215
+ value
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module Attrify
6
+ class Parser
7
+ OPERATIONS = [:append, :prepend, :remove, :set].freeze
8
+ ALLOWED_NESTED_FIELDS = [:data, :aria].freeze
9
+
10
+ class << self
11
+ include Helpers
12
+
13
+ def parse_base(base)
14
+ parse_slots(base)
15
+ end
16
+
17
+ def parse_variants(variants)
18
+ variants.transform_values do |variant_options|
19
+ variant_options.transform_values do |option|
20
+ parse_slots(option)
21
+ end
22
+ end
23
+ end
24
+
25
+ def parse_compounds(compounds)
26
+ raise ArgumentError, "Invalid compounds structure: Expected an Array" unless compounds.is_a?(Array)
27
+ return [] if compounds.empty?
28
+
29
+ compounds.map do |compound|
30
+ # Ensure each compound is a Hash and contains :variant and :attributes keys
31
+ unless compound.is_a?(Hash) && compound.key?(:variants) && compound.key?(:attributes)
32
+ raise ArgumentError, "Invalid compound structure: Each compound must have :variants and :attributes keys"
33
+ end
34
+
35
+ # Parse the attributes section using parse_slots
36
+ {
37
+ variants: compound[:variants], # Keep the variants as they are
38
+ attributes: parse_slots(compound[:attributes]) # Parse the attributes section using parse_slots
39
+ }
40
+ end
41
+ end
42
+
43
+ def parse_defaults(defaults)
44
+ # Ensure the defaults is a hash
45
+ unless defaults.is_a?(Hash)
46
+ raise ArgumentError, "Defaults must be a hash, got #{defaults.class}"
47
+ end
48
+
49
+ # Ensure that all keys and values are symbols
50
+ unless defaults.all? { |key, value| key.is_a?(Symbol) && value.is_a?(Symbol) }
51
+ raise ArgumentError, "Defaults must be a flat hash of symbols. Got: #{defaults.inspect}"
52
+ end
53
+
54
+ defaults
55
+ end
56
+
57
+ def parse_slots(slots)
58
+ parsed_value = parse_slot(slots)
59
+ if parsed_value.key?(:attributes)
60
+ parsed_value = {main: parsed_value}
61
+ end
62
+ parsed_value
63
+ end
64
+
65
+ # {
66
+ # class: [{set: %w[bg-blue-500 text-white]}],
67
+ # style: "width:100px",
68
+ # data: { controller: "stimulus_controller" },
69
+ # # this one is a slot
70
+ # nested: { sub_slot: {class:"red"}, class: "10"}
71
+ # }
72
+ def parse_slot(slot)
73
+ raise ArgumentError, "Invalid slot structure: Expected a Hash #{slot}" unless slot.is_a?(Hash)
74
+
75
+ attributes = slot[:attributes] || {}
76
+
77
+ nested_slots = nested_slots(slot)
78
+ additional_attributes = slot.reject { |key, _| key == :attributes || nested_slots.include?(key) }
79
+
80
+ deep_merge_hashes!(attributes, additional_attributes)
81
+
82
+ parsed_slot = {}
83
+ parsed_slot[:attributes] = parse_attributes(attributes) unless attributes.empty?
84
+
85
+ # Recursively handle nested slots
86
+ nested_slots.each do |nested_slot_name|
87
+ parsed_slot[nested_slot_name] = parse_slot(slot[nested_slot_name])
88
+ end
89
+ parsed_slot
90
+ end
91
+
92
+ # class: [{set: %w[bg-blue-500 text-white]}]
93
+ # style: "width:100px"
94
+ # data: { controller: "stimulus_controller" }
95
+ # class: "red"
96
+ # class: "10"
97
+ def parse_attributes(attributes)
98
+ unless attributes.is_a?(Hash)
99
+ raise ArgumentError, "Invalid attributes list: Expected a Hash, got #{attributes.class}"
100
+ end
101
+
102
+ if is_simple_operation?(attributes)
103
+ raise ArgumentError, "Invalid Attributes List: got an operation"
104
+ end
105
+
106
+ parsed_attributes = {}
107
+
108
+ attributes.each do |key, value|
109
+ parsed_attributes[key] = parse_attribute(key, value)
110
+ end
111
+ parsed_attributes
112
+ end
113
+
114
+ def parse_attribute(key, value)
115
+ unless valid_attribute?(key, value)
116
+ raise ArgumentError, "Invalid attributes list: invalid attribute #{key}"
117
+ end
118
+ unless key.is_a?(Symbol)
119
+ raise ArgumentError, "Attribute: Key must be a symbol #{key}"
120
+ end
121
+
122
+ parse_operations(value)
123
+ end
124
+
125
+ def parse_operations(value)
126
+ case value
127
+ when Hash
128
+ # Check if the hash is a simple operation or needs further parsing
129
+ if is_simple_operation?(value)
130
+ [parse_operation(value)]
131
+ else
132
+ value.transform_values { |v|
133
+ parsed_operation = parse_operations(v)
134
+ if is_simple_operation?(parsed_operation)
135
+ [parsed_operation]
136
+ else
137
+ parsed_operation
138
+ end
139
+ }
140
+ end
141
+ when Array
142
+ if value.any? { |v| is_simple_operation?(v) }
143
+ value.map { |v| parse_operation(v) }
144
+ else
145
+ [parse_operation(value)]
146
+ end
147
+ else
148
+ [parse_operation(value)]
149
+ end
150
+ end
151
+
152
+ def parse_operation(operation)
153
+ case operation
154
+ when Hash
155
+ if !is_simple_operation?(operation)
156
+ raise ArgumentError, "Invalid operation: got #{operation}"
157
+ end
158
+ operation.transform_values { |v|
159
+ if v.is_a?(Array)
160
+ v.map { |x| x.is_a?(Proc) ? x : x.to_s }
161
+ else
162
+ [v.is_a?(Proc) ? v : v.to_s]
163
+ end
164
+ }
165
+ when Array
166
+ {set: operation.map { |v| v.is_a?(Proc) ? v : v.to_s }}
167
+ when Proc
168
+ {set: [operation]}
169
+ else
170
+ {set: Array(operation.to_s)}
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def nested_slots(hash)
177
+ hash.keys.select do |key|
178
+ value = hash[key]
179
+ next false unless value.is_a?(Hash)
180
+
181
+ key != :attributes && !ALLOWED_NESTED_FIELDS.include?(key) && !is_simple_operation?(value)
182
+ end
183
+ end
184
+
185
+ def valid_attribute?(key, value)
186
+ return true unless value.is_a?(Hash)
187
+ ALLOWED_NESTED_FIELDS.include?(key) || is_simple_operation?(value)
188
+ end
189
+
190
+ def is_simple_operation?(operation)
191
+ operation.is_a?(Hash) && (operation.keys.size == 1) && OPERATIONS.include?(operation.keys.first)
192
+ end
193
+
194
+ def array_of_operations?(array)
195
+ return false if array.empty?
196
+ array.all? do |item|
197
+ item.is_a?(Hash) && is_simple_operation?(item)
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "attrify/operation_set"
4
+
5
+ module Attrify
6
+ class Variant
7
+ include Helpers
8
+
9
+ attr_reader :operations
10
+ attr_reader :attributes
11
+ attr_reader :has_procs
12
+
13
+ def initialize(operations)
14
+ @operations = operations
15
+
16
+ result = cache_result(operations)
17
+
18
+ @has_procs = result[:has_procs]
19
+ @attributes = result[:value]
20
+ end
21
+
22
+ def adjust(hash)
23
+ OperationSet.new(deep_merge_hashes(@operations, hash))
24
+ end
25
+
26
+ private
27
+
28
+ def cache_result(hash)
29
+ results = {}
30
+ has_procs = false
31
+
32
+ hash.each do |key, operations|
33
+ if operations.is_a?(Hash)
34
+ result = cache_result(operations)
35
+ results[key] = result[:value]
36
+ has_procs = true if result[:has_procs]
37
+ elsif operations.is_a?(Array)
38
+ current_value = []
39
+ # If any operation is a SET operation, then we can simply perform all operations now
40
+ can_cache_value = operations.any? { |operation| operation.key?(:set) } || key == :main
41
+
42
+ # Process each operation in order
43
+ operations.each do |operation_hash|
44
+ operation_hash.each do |operation, value|
45
+ has_procs = true if value.any? { |c| c.is_a?(Proc) }
46
+ if can_cache_value
47
+ current_value = execute_operation(operation.to_sym, current_value, value)
48
+ end
49
+ end
50
+ end
51
+
52
+ # Flatten array and convert to string if not a hash
53
+ results[key] = if can_cache_value
54
+ current_value # .join(" ")
55
+ else
56
+ operations
57
+ end
58
+ else
59
+ has_procs = true if operations.is_a?(Proc)
60
+ results[key] = operations
61
+ end
62
+ end
63
+
64
+ {value: results, has_procs: has_procs}
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view/helpers/tag_helper"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ require "attrify/parser"
8
+ require "attrify/variant"
9
+ require "attrify/helpers"
10
+
11
+ module Attrify
12
+ class VariantRegistry
13
+ include Helpers
14
+
15
+ attr_reader :base, :variants, :defaults, :compounds
16
+
17
+ def initialize(base: {}, variants: {}, defaults: {}, compounds: [])
18
+ self.base = base
19
+ self.variants = variants
20
+ self.defaults = defaults
21
+ self.compounds = compounds
22
+ @cache = {}
23
+ end
24
+
25
+ def base=(value)
26
+ @base = Parser.parse_base(value)
27
+ end
28
+
29
+ def variants=(value)
30
+ @variants = Parser.parse_variants(value)
31
+ end
32
+
33
+ def compounds=(value)
34
+ @compounds = Parser.parse_compounds(value)
35
+ end
36
+
37
+ def defaults=(value)
38
+ @defaults = Parser.parse_defaults(value)
39
+ end
40
+
41
+ # Fetch the correct variant, with caching
42
+ def fetch(**args)
43
+ # Split args into variant and operations
44
+ variant_keys = variants.keys
45
+ variant_args = {}
46
+ operation_args = {}
47
+
48
+ args.each do |key, value|
49
+ if variant_keys.include?(key)
50
+ variant_args[key] = Array(value).join("_").to_sym
51
+ else
52
+ operation_args[key] = value
53
+ end
54
+ end
55
+
56
+ operations = Parser.parse_slots(operation_args)
57
+
58
+ cache_key = generate_cache_key(variant_args)
59
+
60
+ # Return the cached result if it exists
61
+ unless @cache.key?(cache_key)
62
+ @cache[cache_key] = compute_variant(variant: variant_args)
63
+ end
64
+
65
+ @cache[cache_key].adjust(operations)
66
+ end
67
+
68
+ def initialize_copy(orig)
69
+ super
70
+ @base = @base.dup
71
+ @variants = @variants.dup
72
+ @defaults = @defaults.dup
73
+ @compounds = @compounds.dup
74
+ @cache = {}
75
+ end
76
+
77
+ private
78
+
79
+ # Generate a unique key based on the variants
80
+ def generate_cache_key(variant)
81
+ variant.sort.to_h.to_s
82
+ end
83
+
84
+ def compute_variant(variant: {})
85
+ # Start with our base attributes
86
+ result = @base.dup
87
+
88
+ # Merge default variants with user-specified variants
89
+ selected_variants = @defaults.merge(variant)
90
+
91
+ # Apply selected variants to the base attributes
92
+ selected_variants.each do |variant_type, variant_key|
93
+ variant_defs = @variants.dig(variant_type, variant_key)
94
+ next unless variant_defs
95
+ deep_merge_hashes!(result, variant_defs)
96
+ end
97
+
98
+ # Apply compounds variants
99
+ @compounds.each do |compound_variant|
100
+ if compound_variant[:variants].all? { |key, value| selected_variants[key] == value }
101
+ deep_merge_hashes!(result, compound_variant[:attributes])
102
+ end
103
+ end
104
+
105
+ Variant.new(result)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attrify
4
+ VERSION = "0.4.0"
5
+ end