attrify 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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