view_component_atomix 0.1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +744 -0
  5. data/lib/view_component/atomix/accessors.rb +129 -0
  6. data/lib/view_component/atomix/attribute_helpers.rb +15 -0
  7. data/lib/view_component/atomix/attrs.rb +17 -0
  8. data/lib/view_component/atomix/base.rb +226 -0
  9. data/lib/view_component/atomix/builder.rb +77 -0
  10. data/lib/view_component/atomix/compiler.rb +147 -0
  11. data/lib/view_component/atomix/condition_resolver.rb +79 -0
  12. data/lib/view_component/atomix/configuration.rb +103 -0
  13. data/lib/view_component/atomix/declaration_queue.rb +50 -0
  14. data/lib/view_component/atomix/dynamic_properties/literal_key.rb +68 -0
  15. data/lib/view_component/atomix/dynamic_properties/source_cache.rb +31 -0
  16. data/lib/view_component/atomix/dynamic_properties/source_parser.rb +61 -0
  17. data/lib/view_component/atomix/dynamic_properties.rb +75 -0
  18. data/lib/view_component/atomix/dynamic_style.rb +109 -0
  19. data/lib/view_component/atomix/enumerated_style.rb +39 -0
  20. data/lib/view_component/atomix/hashing.rb +47 -0
  21. data/lib/view_component/atomix/helper.rb +31 -0
  22. data/lib/view_component/atomix/immutable.rb +20 -0
  23. data/lib/view_component/atomix/known_properties.rb +102 -0
  24. data/lib/view_component/atomix/owner_state.rb +48 -0
  25. data/lib/view_component/atomix/property.rb +183 -0
  26. data/lib/view_component/atomix/railtie.rb +46 -0
  27. data/lib/view_component/atomix/registry.rb +208 -0
  28. data/lib/view_component/atomix/resolution_session.rb +81 -0
  29. data/lib/view_component/atomix/rule.rb +42 -0
  30. data/lib/view_component/atomix/sheet.rb +98 -0
  31. data/lib/view_component/atomix/style.rb +80 -0
  32. data/lib/view_component/atomix/tasks/build.rake +17 -0
  33. data/lib/view_component/atomix/theme_compiler.rb +19 -0
  34. data/lib/view_component/atomix/theme_resolution.rb +66 -0
  35. data/lib/view_component/atomix/trait_classifier.rb +111 -0
  36. data/lib/view_component/atomix/vars.rb +97 -0
  37. data/lib/view_component/atomix/version.rb +7 -0
  38. data/lib/view_component/atomix/weak_collection.rb +62 -0
  39. data/lib/view_component/atomix.rb +301 -0
  40. data/lib/view_component_atomix.rb +3 -0
  41. metadata +162 -0
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module Accessors
6
+ ACCESSOR_SUFFIX = /_(styles?|theme|vars|variables)\z/.freeze
7
+
8
+ def styles_method(name = nil)
9
+ return current_styles_method if name.nil?
10
+
11
+ @_styles_method_explicit = true
12
+ install_styles_method(name)
13
+ end
14
+
15
+ private
16
+
17
+ def install_styles_method(name)
18
+ name = name.to_sym
19
+ previous = current_styles_method
20
+ if previous && previous != name
21
+ singleton_class.send(:undef_method, previous) if singleton_class.method_defined?(previous)
22
+ undef_method(previous) if method_defined?(previous)
23
+ end
24
+
25
+ define_singleton_method(name) { |definitions = nil, &block| define_styles(definitions, &block) }
26
+ define_method(name) { self.class.send(:resolved_styles) }
27
+ @_styles_method = name
28
+ invalidate_styles
29
+ styles_mixins.each { |mod| define_mixin_accessors(mod) }
30
+ name
31
+ end
32
+
33
+ def current_styles_method
34
+ return @_styles_method if defined?(@_styles_method)
35
+
36
+ is_a?(Class) && superclass.respond_to?(:styles_method) ? superclass.styles_method : nil
37
+ end
38
+
39
+ def styles_method_explicit?
40
+ defined?(@_styles_method_explicit) && @_styles_method_explicit
41
+ end
42
+
43
+ def styles_mixins
44
+ @_styles_mixins ||= []
45
+ end
46
+
47
+ def inherited_styles_mixin_accessors
48
+ return {} unless is_a?(Class) && superclass.respond_to?(:styles_mixin_accessors, true)
49
+
50
+ superclass.send(:styles_mixin_accessors)
51
+ end
52
+
53
+ # Separate readers select the last included module. Once a reader is
54
+ # the primary accessor, its modules are represented by inherited_styles
55
+ # instead, including any declarations which override them on that owner.
56
+ def styles_mixin_accessors
57
+ accessors = inherited_styles_mixin_accessors
58
+ styles_mixins.each do |mod|
59
+ accessors[styles_accessor_for(mod)] = mod if mod < Variables
60
+ end
61
+ accessors.delete(current_styles_method)
62
+ accessors
63
+ end
64
+
65
+ def accessor_base(mod)
66
+ mod.name.underscore.tr("/", "_").sub(ACCESSOR_SUFFIX, "")
67
+ end
68
+
69
+ def styles_accessor_for(mod)
70
+ return mod.styles_method if mod.name.nil? || mod.send(:styles_method_explicit?)
71
+
72
+ :"#{accessor_base(mod)}_styles"
73
+ end
74
+
75
+ def theme_accessor_for(mod)
76
+ return :theme if mod.name.nil?
77
+
78
+ :"#{accessor_base(mod)}_theme"
79
+ end
80
+
81
+ def companion_styles_module
82
+ return styles_state.companion_styles_module if styles_state.companion_styles_module
83
+ return unless is_a?(Class) && name
84
+
85
+ candidate = "#{name}Styles".safe_constantize
86
+ return unless candidate.instance_of?(Module) && candidate < Base
87
+
88
+ styles_state.companion_styles_module = candidate
89
+ end
90
+
91
+ def companion_styles
92
+ mod = companion_styles_module
93
+ mod && mod < Variables ? mod.send(:resolved_styles) : Sheet::EMPTY
94
+ end
95
+
96
+ def companion_theme
97
+ mod = companion_styles_module
98
+ mod && mod < Theme ? mod.send(:resolved_theme) : Style::EMPTY
99
+ end
100
+
101
+ def add_styles_mixin(mod)
102
+ styles_mixins << mod
103
+ invalidate_styles
104
+ define_mixin_accessors(mod)
105
+ end
106
+
107
+ def define_mixin_accessors(mod)
108
+ if mod < Variables
109
+ accessor = styles_accessor_for(mod)
110
+ unless accessor == current_styles_method
111
+ define_singleton_method(accessor) { mod.send(:resolved_styles) }
112
+ define_method(accessor) { mod.send(:resolved_styles) }
113
+
114
+ leaked = mod.styles_method
115
+ undef_method(leaked) if leaked != accessor && leaked != current_styles_method && method_defined?(leaked)
116
+ end
117
+ end
118
+
119
+ return unless mod < Theme
120
+
121
+ accessor = theme_accessor_for(mod)
122
+ return if accessor == :theme
123
+
124
+ define_singleton_method(accessor) { mod.send(:resolved_theme) }
125
+ define_method(accessor) { mod.send(:resolved_theme) }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module AttributeHelpers
6
+ def sx(*styles)
7
+ ViewComponent::Atomix.sx(*styles)
8
+ end
9
+
10
+ def sx_attrs(*styles)
11
+ ViewComponent::Atomix.props(*styles)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Attrs < Hash
6
+ def to_s
7
+ html = map { |name, value| "#{name}=\"#{ERB::Util.html_escape(value)}\"" }.join(" ")
8
+ ActiveSupport::SafeBuffer.new(html)
9
+ end
10
+ alias to_str to_s
11
+
12
+ def html_safe?
13
+ true
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module Base
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ ViewComponent::Atomix.includers << self
10
+ extend Mixin unless is_a?(Class)
11
+ end
12
+
13
+ class_methods do
14
+ include Accessors
15
+ include ThemeResolution
16
+
17
+ private
18
+
19
+ def styles_state
20
+ @_styles_state ||= OwnerState.new
21
+ end
22
+
23
+ def reset_styles!
24
+ styles_state.reset!
25
+ end
26
+
27
+ def invalidate_styles_cache!
28
+ styles_state.invalidate!
29
+ end
30
+
31
+ def unload_styles!
32
+ styles_state.unload!
33
+ end
34
+
35
+ def styles_unloaded?
36
+ styles_state.unloaded?
37
+ end
38
+
39
+ def define_styles(definitions = nil, &block)
40
+ return resolved_styles if definitions.nil? && block.nil?
41
+
42
+ unless self < ViewComponent::Atomix
43
+ raise ArgumentError, "#{self} includes ViewComponent::Atomix::Variables only. " \
44
+ "Include ViewComponent::Atomix to declare traits."
45
+ end
46
+
47
+ source = block ? block.source_location.first : caller_locations(2, 1).first.path
48
+ styles_resolution_monitor.synchronize do
49
+ styles_state.styles << DeclarationQueue::StyleDeclaration.new(block: block || proc { definitions }, source: source)
50
+ invalidate_styles
51
+ end
52
+ nil
53
+ end
54
+
55
+ def invalidate_styles
56
+ ViewComponent::Atomix.invalidate_caches!
57
+ end
58
+
59
+ def resolved_styles
60
+ resolve_owner(:styles) do |reentrant|
61
+ reentrant ? compile_pending_vars : compile_pending_styles
62
+ styles_context
63
+ end
64
+ end
65
+
66
+ def resolve_owner(kind)
67
+ cached = kind == :styles ? styles_state.styles_resolved : styles_state.theme_resolved
68
+ return cached if cached
69
+
70
+ styles_resolution_monitor.synchronize do
71
+ ResolutionSession.with do |session|
72
+ resolving = :"#{kind}_resolving"
73
+ cache = :"#{kind}_resolved"
74
+ if styles_state.public_send(resolving)
75
+ session.provisional!
76
+ return session.nested { yield true }
77
+ end
78
+ cached = styles_state.public_send(cache)
79
+ return cached if cached
80
+
81
+ outermost = !session.resolving?
82
+ result = nil
83
+ # An outer read gets one fresh cache-building pass after active
84
+ # dependencies unwind. Successful declarations remain consumed.
85
+ 2.times do
86
+ provisional = false
87
+ session.frame do |frame|
88
+ # Immediate style_vars can drain deferred variables outside a
89
+ # styles read. Their in-progress owner is provisional too.
90
+ session.provisional! if kind == :styles && styles_state.variables_resolving
91
+ styles_state.public_send(:"#{resolving}=", true)
92
+ begin
93
+ result = resolve_stable(frame) { yield false }
94
+ provisional = frame.provisional
95
+ styles_state.public_send(:"#{cache}=", result) unless provisional
96
+ rescue StandardError
97
+ styles_state.public_send(:"#{cache}=", nil)
98
+ raise
99
+ ensure
100
+ styles_state.public_send(:"#{resolving}=", false)
101
+ end
102
+ end
103
+ break unless outermost && provisional
104
+
105
+ outermost = false
106
+ end
107
+ result
108
+ end
109
+ end
110
+ end
111
+
112
+ def styles_resolution_monitor
113
+ RESOLUTION_MONITOR
114
+ end
115
+
116
+ # Dependencies can declare more work on this owner while its context is
117
+ # being assembled. Never overwrite their invalidation with a stale sheet.
118
+ def resolve_stable(frame)
119
+ ResolutionSession::MAX_STABILITY_PASSES.times do
120
+ frame.stale = false
121
+ result = yield
122
+ return result unless frame.stale
123
+
124
+ ResolutionSession.with(&:consume!)
125
+ end
126
+ raise ArgumentError, "Style dependencies keep changing during resolution"
127
+ end
128
+
129
+ def inherited_styles
130
+ return Sheet::EMPTY unless is_a?(Class) && superclass.respond_to?(:resolved_styles, true)
131
+
132
+ superclass.send(:resolved_styles)
133
+ end
134
+
135
+ def compile_pending_styles
136
+ compile_pending_vars
137
+ styles_state.styles.drain do |declaration|
138
+ ViewComponent::Atomix.registry.with_source(declaration.source) do
139
+ definitions, breakpoints = Builder.evaluate(self, declaration.block)
140
+ sheet = commit_owner_declaration do
141
+ previous_breakpoints = styles_state.styles_breakpoints
142
+ begin
143
+ add_breakpoints(breakpoints)
144
+ Compiler.compile(definitions, name: name || "anonymous", context: styles_context,
145
+ breakpoints: styles_breakpoints)
146
+ rescue StandardError
147
+ styles_state.styles_breakpoints = previous_breakpoints
148
+ raise
149
+ end
150
+ end
151
+ merge_own_styles(sheet)
152
+ end
153
+ end
154
+ rescue StandardError
155
+ styles_state.styles_resolved = nil
156
+ raise
157
+ end
158
+
159
+ def compile_pending_vars
160
+ previous = styles_state.variables_resolving
161
+ styles_state.variables_resolving = true
162
+ styles_state.variables.drain do |declaration|
163
+ ViewComponent::Atomix.registry.with_source(declaration.source) do
164
+ definitions = declaration.block.call
165
+ add_vars(declaration.label, definitions)
166
+ end
167
+ end
168
+ rescue StandardError
169
+ styles_state.styles_resolved = nil
170
+ raise
171
+ ensure
172
+ styles_state.variables_resolving = previous
173
+ end
174
+
175
+ def merge_own_styles(sheet)
176
+ styles_state.styles_own = styles_state.styles_own ? styles_state.styles_own.merge(sheet) : sheet
177
+ end
178
+
179
+ def add_breakpoints(map)
180
+ return if map.empty?
181
+
182
+ # Inherited queries remain inherited so later parent declarations
183
+ # cannot be shadowed by a snapshot taken on this owner.
184
+ styles_state.styles_breakpoints = (styles_state.styles_breakpoints || {}).merge(map)
185
+ end
186
+
187
+ def styles_breakpoints
188
+ inherited = if is_a?(Class) && superclass.respond_to?(:styles_breakpoints, true)
189
+ superclass.send(:styles_breakpoints)
190
+ else
191
+ {}
192
+ end
193
+ inherited.merge(styles_state.styles_breakpoints || {})
194
+ end
195
+
196
+ def styles_context
197
+ matching_mixins = styles_mixins.select { |mod| mod < Variables && styles_accessor_for(mod) == current_styles_method }
198
+ inherited_mixin = inherited_styles_mixin_accessors[current_styles_method]
199
+ layers = [inherited_styles, inherited_mixin ? inherited_mixin.send(:resolved_styles) : Sheet::EMPTY,
200
+ companion_styles, *matching_mixins.map { |mod| mod.send(:resolved_styles) },
201
+ styles_state.styles_own || Sheet::EMPTY]
202
+ layers.reduce(Sheet::EMPTY, :merge)
203
+ end
204
+
205
+ # Owner results survive a consuming transaction's failure, so their CSS
206
+ # must commit independently. Keep user block evaluation outside this
207
+ # boundary; publish the compiled result on the owner only
208
+ # after it succeeds. Source attribution remains around both phases.
209
+ def commit_owner_declaration(&block)
210
+ ViewComponent::Atomix.registry.transaction(independent: true, &block)
211
+ end
212
+
213
+ def add_vars(label, definitions)
214
+ vars = commit_owner_declaration { Vars.new(label, definitions) }
215
+ merge_own_styles(Sheet.new({}, vars.names.keys.to_h { |key| [key, vars] }))
216
+ end
217
+
218
+ def macro_label(name)
219
+ return name.to_s if name
220
+
221
+ self.name || "anonymous:#{caller_locations(2, 1).first&.then { |l| "#{l.path}:#{l.lineno}" }}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Builder
6
+ attr_reader :definitions, :declared_breakpoints
7
+
8
+ def initialize(owner)
9
+ @owner = owner
10
+ @definitions = {}
11
+ @declared_breakpoints = {}
12
+ end
13
+
14
+ def breakpoints(**map)
15
+ Atomix.config.validate_breakpoints!(map)
16
+ @declared_breakpoints.merge!(map)
17
+ nil
18
+ end
19
+
20
+ def trait(name, *args, **definition)
21
+ raise ArgumentError, "trait name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
22
+ raise ArgumentError, "trait #{name.inspect} is already declared in this block" if @definitions.key?(name)
23
+
24
+ @definitions[name] = build_definition(name, args, definition)
25
+ nil
26
+ end
27
+
28
+ def self.evaluate(owner, block)
29
+ builder = new(owner)
30
+ returned = builder.instance_exec(&block)
31
+ definitions = builder.definitions
32
+ if definitions.empty?
33
+ unless returned.nil? || returned.is_a?(Hash)
34
+ raise ArgumentError, "styles block must declare traits or return a Hash, got #{returned.class}"
35
+ end
36
+
37
+ definitions = returned || {}
38
+ elsif returned.is_a?(Hash)
39
+ raise ArgumentError, "styles block must either declare traits or return a Hash, not both"
40
+ end
41
+
42
+ [definitions, builder.declared_breakpoints]
43
+ end
44
+
45
+ private
46
+
47
+ def build_definition(name, args, definition)
48
+ merged = {}
49
+ args.each do |arg|
50
+ case arg
51
+ when Symbol then merged[arg] = true
52
+ when Hash then merged.merge!(arg)
53
+ when Proc
54
+ raise ArgumentError, "trait #{name.inspect} cannot combine a lambda with other definitions" unless args.one? && definition.empty?
55
+
56
+ return arg
57
+ else
58
+ raise ArgumentError, "trait #{name.inspect} accepts trait symbols, a Hash, or a lambda, got #{arg.inspect}"
59
+ end
60
+ end
61
+ merged.merge!(definition)
62
+ end
63
+
64
+ def method_missing(name, *args, **kwargs, &block)
65
+ if @owner&.respond_to?(name)
66
+ @owner.public_send(name, *args, **kwargs, &block)
67
+ else
68
+ super
69
+ end
70
+ end
71
+
72
+ def respond_to_missing?(name, include_private = false)
73
+ @owner&.respond_to?(name) || super
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Compiler
6
+ def self.compile(definitions, name:, context: Sheet::EMPTY, breakpoints: {})
7
+ Atomix.registry.transaction { new(name, context, breakpoints).compile(definitions) }
8
+ end
9
+
10
+ def initialize(name, context = Sheet::EMPTY, breakpoints = {})
11
+ @name = name
12
+ @context = context
13
+ @conditions = Atomix.config.condition_resolver(Atomix.config.breakpoints.merge(breakpoints))
14
+ @sets = {}
15
+ @classifier = TraitClassifier.new(sets: @sets, inherited_sets: context.sets, conditions: @conditions)
16
+ end
17
+
18
+ def compile(definitions)
19
+ unless definitions.is_a?(Hash)
20
+ raise ArgumentError, "Style definitions must be a Hash of traits, got #{definitions.class}"
21
+ end
22
+
23
+ definitions.each do |set_name, definition|
24
+ set_name = set_name.to_sym
25
+ @sets[set_name] = compile_set(set_name, definition)
26
+ end
27
+ Sheet.new(@sets)
28
+ end
29
+
30
+ private
31
+
32
+ def compile_set(set_name, definition)
33
+ result = @classifier.classify(set_name, definition)
34
+ case result.kind
35
+ when :dynamic
36
+ DynamicStyle.new(name: @name, set: set_name, fn: result.fn, properties: result.properties)
37
+ when :enumerated
38
+ compile_enumerated(set_name, result.members, result.default)
39
+ when :combination
40
+ compile_combination(set_name, result.references, result.properties)
41
+ when :plain
42
+ compile_style(result.properties)
43
+ end
44
+ end
45
+
46
+ def lookup(key)
47
+ @sets[key] || (@context.sets[key] if @context.sets.key?(key))
48
+ end
49
+
50
+ def compile_enumerated(set_name, members, default)
51
+ members.each do |member, styles|
52
+ condition = styles.keys.find { |key| @conditions.condition?(key) && !@conditions.pseudo_element?(key) }
53
+ next unless condition
54
+
55
+ raise ArgumentError,
56
+ "Enumerated member #{member.inspect} in trait #{set_name.inspect} contains condition #{condition.inspect}. " \
57
+ "Conditions must be nested under a property. If #{member.inspect} was intended as a property, check its spelling."
58
+ end
59
+ compiled = members.to_h { |member, styles| [member, compile_style(styles)] }
60
+ if default
61
+ default = default.to_sym
62
+ unless compiled.key?(default)
63
+ raise ArgumentError,
64
+ "Default #{default.inspect} is not a member of trait #{set_name.inspect}. " \
65
+ "Members: #{compiled.keys.inspect}"
66
+ end
67
+ end
68
+ EnumeratedStyle.new(name: set_name, members: compiled, default: default)
69
+ end
70
+
71
+ def compile_combination(set_name, references, properties)
72
+ styles = references.map { |key, value| resolve_reference(set_name, key, value) }
73
+ styles << compile_style(properties) if properties.any?
74
+ Atomix.merge(*styles)
75
+ end
76
+
77
+ def resolve_reference(set_name, key, value)
78
+ target = lookup(key)
79
+ case target
80
+ when EnumeratedStyle
81
+ if value == true
82
+ raise ArgumentError, "Trait #{key.inspect} in #{set_name.inspect} is enumerated; pass a member such as #{key}: #{target.member_names.first.inspect}" if target.default.nil?
83
+ target.call
84
+ else
85
+ target.call(value)
86
+ end
87
+ when DynamicStyle
88
+ raise ArgumentError, "Trait #{set_name.inspect} cannot reference the dynamic trait #{key.inspect}"
89
+ else
90
+ unless value == true
91
+ raise ArgumentError, "Trait #{key.inspect} in #{set_name.inspect} is not enumerated; use #{key}: true or :#{key}"
92
+ end
93
+ target
94
+ end
95
+ end
96
+
97
+ def compile_style(definition)
98
+ classes = {}
99
+
100
+ definition.each do |key, value|
101
+ if @conditions.pseudo_element?(key)
102
+ condition = @conditions.resolve(key)
103
+ unless value.is_a?(Hash)
104
+ raise ArgumentError, "Pseudo-element #{key.inspect} must map to a Hash of properties"
105
+ end
106
+
107
+ value.each do |prop_key, prop_value|
108
+ property = Property.normalize_key(prop_key)
109
+ classes["#{condition}#{property}"] = compile_property(property, prop_value, [condition])
110
+ end
111
+ elsif @conditions.condition?(key)
112
+ raise ArgumentError,
113
+ "Condition #{key.inspect} must be nested under a property, e.g. " \
114
+ "color: { default: \"red\", #{key.inspect} => \"blue\" }"
115
+ else
116
+ property = Property.normalize_key(key)
117
+ classes[property] = compile_property(property, value, [])
118
+ end
119
+ end
120
+
121
+ Style.new(classes)
122
+ end
123
+
124
+ def compile_property(property, value, conditions)
125
+ case value
126
+ when nil
127
+ conditions.empty? ? nil : []
128
+ when Hash
129
+ value.flat_map do |key, nested|
130
+ if key == :default
131
+ Array(compile_property(property, nested, conditions))
132
+ else
133
+ compile_property(property, nested, conditions + [@conditions.resolve(key)])
134
+ end
135
+ end
136
+ else
137
+ normalized = Property.normalize_value(property, value)
138
+ class_name = Hashing.class_name(property, normalized, conditions)
139
+ Atomix.registry.add(
140
+ Rule.new(selector: ".#{class_name}", property: property, value: normalized, conditions: conditions)
141
+ )
142
+ [class_name]
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ # One effective condition vocabulary per compilation. Variables use the
6
+ # global map; component traits include their locally declared breakpoints.
7
+ class ConditionResolver
8
+ attr_reader :breakpoints, :pseudo_map
9
+
10
+ def initialize(breakpoints:, pseudo_map:)
11
+ self.class.validate_pseudo_map!(pseudo_map)
12
+ self.class.validate_breakpoints!(breakpoints, pseudo_map: pseudo_map)
13
+ @breakpoints = Immutable.copy(breakpoints)
14
+ @pseudo_map = Immutable.copy(pseudo_map)
15
+ end
16
+
17
+ def breakpoint?(key)
18
+ key.is_a?(Symbol) && breakpoints.key?(key)
19
+ end
20
+
21
+ def condition?(key)
22
+ case key
23
+ when String then key.start_with?(":", "@")
24
+ when Symbol then pseudo_map.key?(key) || breakpoint?(key)
25
+ else false
26
+ end
27
+ end
28
+
29
+ def pseudo_element?(key)
30
+ resolved = key.is_a?(String) ? key : pseudo_map[key]
31
+ resolved.is_a?(String) && resolved.start_with?("::")
32
+ end
33
+
34
+ def self.validate_pseudo_map!(map)
35
+ raise ArgumentError, "pseudo_map must be a Hash, got #{map.class}" unless map.is_a?(Hash)
36
+
37
+ map.each do |key, value|
38
+ raise ArgumentError, "pseudo_map key must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
39
+ unless value.is_a?(String) && value.start_with?(":", "@")
40
+ raise ArgumentError, "pseudo_map #{key.inspect} must be a String starting with ':' or '@', got #{value.inspect}"
41
+ end
42
+ end
43
+ end
44
+
45
+ def self.validate_breakpoints!(map, pseudo_map:)
46
+ raise ArgumentError, "breakpoints must be a Hash, got #{map.class}" unless map.is_a?(Hash)
47
+
48
+ map.each do |key, query|
49
+ raise ArgumentError, "breakpoint name must be a Symbol, got #{key.inspect}" unless key.is_a?(Symbol)
50
+ raise ArgumentError, "breakpoint #{key.inspect} must be a String, got #{query.inspect}" unless query.is_a?(String)
51
+ if pseudo_map.key?(key)
52
+ raise ArgumentError, "breakpoint #{key.inspect} collides with the pseudo-class or media shortcut of the same name"
53
+ end
54
+ end
55
+ end
56
+
57
+ def resolve(key)
58
+ case key
59
+ when Symbol
60
+ if (breakpoint = breakpoints[key])
61
+ return breakpoint.start_with?("@") ? breakpoint : "@media #{breakpoint}"
62
+ end
63
+
64
+ pseudo_map[key] ||
65
+ raise(ArgumentError, "Unknown style condition #{key.inspect}. " \
66
+ "Use a known pseudo-class symbol, a configured breakpoint, " \
67
+ "or a string such as \":nth-child(2)\" or \"@media (...)\".")
68
+ when String
69
+ unless key.start_with?(":", "@")
70
+ raise ArgumentError, "Style condition strings must start with ':' or '@', got #{key.inspect}"
71
+ end
72
+ key
73
+ else
74
+ raise ArgumentError, "Unsupported style condition #{key.inspect}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end