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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +744 -0
- data/lib/view_component/atomix/accessors.rb +129 -0
- data/lib/view_component/atomix/attribute_helpers.rb +15 -0
- data/lib/view_component/atomix/attrs.rb +17 -0
- data/lib/view_component/atomix/base.rb +226 -0
- data/lib/view_component/atomix/builder.rb +77 -0
- data/lib/view_component/atomix/compiler.rb +147 -0
- data/lib/view_component/atomix/condition_resolver.rb +79 -0
- data/lib/view_component/atomix/configuration.rb +103 -0
- data/lib/view_component/atomix/declaration_queue.rb +50 -0
- data/lib/view_component/atomix/dynamic_properties/literal_key.rb +68 -0
- data/lib/view_component/atomix/dynamic_properties/source_cache.rb +31 -0
- data/lib/view_component/atomix/dynamic_properties/source_parser.rb +61 -0
- data/lib/view_component/atomix/dynamic_properties.rb +75 -0
- data/lib/view_component/atomix/dynamic_style.rb +109 -0
- data/lib/view_component/atomix/enumerated_style.rb +39 -0
- data/lib/view_component/atomix/hashing.rb +47 -0
- data/lib/view_component/atomix/helper.rb +31 -0
- data/lib/view_component/atomix/immutable.rb +20 -0
- data/lib/view_component/atomix/known_properties.rb +102 -0
- data/lib/view_component/atomix/owner_state.rb +48 -0
- data/lib/view_component/atomix/property.rb +183 -0
- data/lib/view_component/atomix/railtie.rb +46 -0
- data/lib/view_component/atomix/registry.rb +208 -0
- data/lib/view_component/atomix/resolution_session.rb +81 -0
- data/lib/view_component/atomix/rule.rb +42 -0
- data/lib/view_component/atomix/sheet.rb +98 -0
- data/lib/view_component/atomix/style.rb +80 -0
- data/lib/view_component/atomix/tasks/build.rake +17 -0
- data/lib/view_component/atomix/theme_compiler.rb +19 -0
- data/lib/view_component/atomix/theme_resolution.rb +66 -0
- data/lib/view_component/atomix/trait_classifier.rb +111 -0
- data/lib/view_component/atomix/vars.rb +97 -0
- data/lib/view_component/atomix/version.rb +7 -0
- data/lib/view_component/atomix/weak_collection.rb +62 -0
- data/lib/view_component/atomix.rb +301 -0
- data/lib/view_component_atomix.rb +3 -0
- 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
|