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,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ Rule = Struct.new(:selector, :property, :value, :conditions, :priority, keyword_init: true) do
6
+ def initialize(selector:, property:, value:, conditions: [], priority: nil)
7
+ super(
8
+ selector: Immutable.copy(selector),
9
+ property: Immutable.copy(property),
10
+ value: Immutable.copy(value),
11
+ conditions: Immutable.copy(conditions),
12
+ priority: priority || Property.priority(property, conditions)
13
+ )
14
+ freeze
15
+ end
16
+
17
+ def to_css
18
+ pseudos = conditions.reject { |c| c.start_with?("@") }
19
+ .sort_by { |c| c.start_with?("::") ? 1 : 0 }
20
+ at_rules = conditions.select { |c| c.start_with?("@") }
21
+ .sort_by { |c| Property.condition_priority(c) }
22
+
23
+ # StyleX adds one class per at-rule so a matching media query can
24
+ # override a pseudo-class even when cascade layers are disabled.
25
+ repetitions = 0
26
+ if selector.start_with?(".")
27
+ repetitions = at_rules.length
28
+ repetitions += 1 if pseudos.any? { |pseudo| pseudo.include?(":where(") }
29
+ end
30
+ css = "#{selector * (1 + repetitions)}#{pseudos.join}{#{declarations}}"
31
+ at_rules.reverse_each { |at| css = "#{at}{#{css}}" }
32
+ css
33
+ end
34
+
35
+ private
36
+
37
+ def declarations
38
+ Array(value).map { |v| "#{property}:#{v}" }.join(";")
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Sheet
6
+ attr_reader :sets, :vars
7
+
8
+ def initialize(sets = {}, vars = {})
9
+ @sets = Immutable.copy(sets)
10
+ @vars = Immutable.copy(vars)
11
+ freeze
12
+ end
13
+
14
+ EMPTY = new
15
+
16
+ def [](name)
17
+ return nil if name.nil?
18
+
19
+ key = name.to_sym
20
+ return sets[key] if sets.key?(key)
21
+
22
+ owner = vars_for(key)
23
+ return owner[key] if owner
24
+
25
+ raise KeyError, "Unknown trait #{name.inspect}. Available sets: #{names.inspect}, vars: #{var_names.inspect}"
26
+ end
27
+
28
+ def fetch(name, *args, **kwargs)
29
+ invoke(name, self[name], *args, **kwargs)
30
+ end
31
+
32
+ def key?(name)
33
+ sets.key?(name.to_sym) || !vars_for(name).nil?
34
+ end
35
+
36
+ def names
37
+ sets.keys
38
+ end
39
+
40
+ def var_names
41
+ vars.keys
42
+ end
43
+
44
+ def vars_for(name)
45
+ key = name.to_sym
46
+ return vars[key] if vars.key?(key)
47
+
48
+ global = ViewComponent::Atomix.configured_vars
49
+ global if global&.names&.key?(key)
50
+ end
51
+
52
+ def empty?
53
+ sets.empty? && vars.empty?
54
+ end
55
+
56
+ def merge(other)
57
+ return self if other.nil? || other.empty?
58
+
59
+ Sheet.new(sets.merge(other.sets), vars.merge(other.vars))
60
+ end
61
+
62
+ def to_h
63
+ sets
64
+ end
65
+
66
+ def props(*styles)
67
+ ViewComponent::Atomix.props(*styles)
68
+ end
69
+
70
+ def method_missing(name, *args, **kwargs, &block)
71
+ if sets.key?(name)
72
+ invoke(name, sets[name], *args, **kwargs)
73
+ elsif args.empty? && kwargs.empty? && (owner = vars_for(name))
74
+ owner[name]
75
+ else
76
+ super
77
+ end
78
+ end
79
+
80
+ def respond_to_missing?(name, include_private = false)
81
+ sets.key?(name) || !vars_for(name).nil? || super
82
+ end
83
+
84
+ private
85
+
86
+ def invoke(name, set, *args, **kwargs)
87
+ return set.call(*args, **kwargs) if callable?(set)
88
+ return set if args.empty? && kwargs.empty?
89
+
90
+ raise ArgumentError, "Trait #{name.inspect} is static and takes no arguments"
91
+ end
92
+
93
+ def callable?(set)
94
+ set.is_a?(DynamicStyle) || set.is_a?(EnumeratedStyle)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Style
6
+ attr_reader :classes, :inline
7
+
8
+ def initialize(classes = {}, inline = {})
9
+ assign(Immutable.copy(classes), Immutable.copy(inline))
10
+ end
11
+
12
+ def self.combine(styles)
13
+ styles = styles.reject(&:empty?)
14
+ return EMPTY if styles.empty?
15
+ return styles.first if styles.one?
16
+
17
+ classes = {}
18
+ inline = {}
19
+ styles.each do |style|
20
+ classes.update(style.classes)
21
+ inline.update(style.inline)
22
+ end
23
+ allocate.send(:assign, classes.freeze, inline.freeze)
24
+ end
25
+
26
+ def merge(other)
27
+ return self if other.nil? || other.empty?
28
+
29
+ Style.combine([self, other])
30
+ end
31
+
32
+ def empty?
33
+ classes.empty? && inline.empty?
34
+ end
35
+
36
+ def class_names
37
+ names = classes.values.compact.flatten
38
+ names.empty? ? nil : names.join(" ")
39
+ end
40
+
41
+ def inline_style
42
+ return nil if inline.empty?
43
+
44
+ inline.map { |name, value| "#{name}:#{value}" }.join(";")
45
+ end
46
+
47
+ def to_attrs
48
+ attrs = Attrs.new
49
+ names = class_names
50
+ declarations = inline_style
51
+ attrs[:class] = names if names
52
+ attrs[:style] = declarations if declarations
53
+ attrs
54
+ end
55
+
56
+ def to_s
57
+ class_names.to_s
58
+ end
59
+
60
+ def ==(other)
61
+ other.is_a?(Style) && classes == other.classes && inline == other.inline
62
+ end
63
+ alias eql? ==
64
+
65
+ def hash
66
+ [classes, inline].hash
67
+ end
68
+
69
+ private
70
+
71
+ def assign(classes, inline)
72
+ @classes = classes
73
+ @inline = inline
74
+ freeze
75
+ end
76
+
77
+ EMPTY = new
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :view_component do
4
+ namespace :atomix do
5
+ desc "Compile all ViewComponent::Atomix rules into a CSS file"
6
+ task build: :environment do
7
+ Rails.application.eager_load!
8
+ ViewComponent::Atomix.eager_load!
9
+ ViewComponent::Atomix.compile_dynamic_styles!
10
+ path = ViewComponent::Atomix.write_css(force: true)
11
+ puts "Wrote #{ViewComponent::Atomix.registry.size} rules to #{path}"
12
+ end
13
+ end
14
+ end
15
+
16
+ task "assets:precompile"
17
+ Rake::Task["assets:precompile"].prerequisites.unshift("view_component:atomix:build")
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module ThemeCompiler
6
+ # A variable group may span multiple style_vars declarations.
7
+ def self.compile(id, names, overrides)
8
+ digest_input = overrides.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}=#{v.inspect}" }
9
+ class_name = Hashing.class_name("theme", id, digest_input)
10
+ Atomix.registry.transaction do
11
+ overrides.each do |key, value|
12
+ Vars.register(".#{class_name}", names.fetch(key.to_sym), value)
13
+ end
14
+ end
15
+ Style.new({ "theme:#{id}" => [class_name] })
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module ThemeResolution
6
+ private
7
+
8
+ def resolved_theme
9
+ resolve_owner(:theme) do |reentrant|
10
+ reentrant ? theme_context : compile_theme
11
+ end
12
+ end
13
+
14
+ def compile_theme
15
+ styles_state.themes.drain do |declaration|
16
+ built = ViewComponent::Atomix.registry.with_source(declaration.source) do
17
+ build_theme(declaration.vars, declaration.overrides, declaration.block)
18
+ end
19
+ styles_state.theme_own = (styles_state.theme_own || Style::EMPTY).merge(built)
20
+ end
21
+
22
+ theme_context
23
+ end
24
+
25
+ def theme_context
26
+ inherited = inherited_theme
27
+ matching_mixins = styles_mixins.select { |mod| mod < Theme && theme_accessor_for(mod) == :theme }
28
+ layers = [inherited, companion_theme, *matching_mixins.map { |mod| mod.send(:resolved_theme) },
29
+ styles_state.theme_own || Style::EMPTY]
30
+ layers.reduce(Style::EMPTY, :merge)
31
+ end
32
+
33
+ def inherited_theme
34
+ return Style::EMPTY unless is_a?(Class) && superclass.respond_to?(:resolved_theme, true)
35
+
36
+ superclass.send(:resolved_theme)
37
+ end
38
+
39
+ def build_theme(vars, overrides, block)
40
+ overrides = block.call if block
41
+ unless overrides.is_a?(Hash)
42
+ raise ArgumentError, "Theme overrides must be a Hash, got #{overrides.class}"
43
+ end
44
+
45
+ if vars
46
+ return commit_owner_declaration do
47
+ ViewComponent::Atomix.create_theme(vars, **overrides)
48
+ end
49
+ end
50
+
51
+ sheet = resolved_styles
52
+ grouped = overrides.group_by do |key, _|
53
+ owner = sheet.vars_for(key) ||
54
+ raise(KeyError, "Unknown var #{key.inspect} in theme for #{self}. Available vars: #{sheet.var_names.inspect}")
55
+ owner.id
56
+ end
57
+ commit_owner_declaration do
58
+ grouped.map do |id, pairs|
59
+ names = pairs.to_h { |key, _| [key.to_sym, sheet.vars_for(key).var_name(key)] }
60
+ ThemeCompiler.compile(id, names, pairs.to_h)
61
+ end.reduce(Style::EMPTY, :merge)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ # Classifies the DSL without emitting CSS. Available references contain only
6
+ # inherited sets and sets compiled earlier in declaration order.
7
+ class TraitClassifier
8
+ Result = Struct.new(:kind, :properties, :references, :members, :default, :fn, keyword_init: true)
9
+
10
+ def initialize(sets:, inherited_sets:, conditions:)
11
+ @sets = sets
12
+ @inherited_sets = inherited_sets
13
+ @conditions = conditions
14
+ end
15
+
16
+ def classify(set_name, definition)
17
+ if Property.known?(Property.normalize_key(set_name))
18
+ raise ArgumentError, "Trait #{set_name.inspect} is named after a CSS property. Pick another name."
19
+ end
20
+ if Sheet.method_defined?(set_name)
21
+ raise ArgumentError, "Trait #{set_name.inspect} collides with a method on the styles sheet. Pick another name."
22
+ end
23
+
24
+ case definition
25
+ when Proc
26
+ Result.new(kind: :dynamic, fn: definition)
27
+ when Hash
28
+ if definition.key?(:dynamic) || definition.key?(:using)
29
+ classify_dynamic(set_name, definition)
30
+ else
31
+ classify_hash(set_name, definition)
32
+ end
33
+ else
34
+ raise ArgumentError, "Trait #{set_name.inspect} must be a Hash or a lambda, got #{definition.class}"
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def classify_dynamic(set_name, definition)
41
+ unknown = definition.keys - %i[dynamic using]
42
+ raise ArgumentError, "Dynamic trait #{set_name.inspect} has unknown options #{unknown.inspect}" if unknown.any?
43
+
44
+ properties = definition[:dynamic]
45
+ fn = definition[:using]
46
+ unless properties.is_a?(Array) && properties.all? { |property| property.is_a?(Symbol) || property.is_a?(String) }
47
+ raise ArgumentError, "Dynamic trait #{set_name.inspect} requires dynamic: with an Array of property names"
48
+ end
49
+ raise ArgumentError, "Dynamic trait #{set_name.inspect} requires using: with a lambda" unless fn.is_a?(Proc)
50
+
51
+ Result.new(kind: :dynamic, fn: fn, properties: properties)
52
+ end
53
+
54
+ def classify_hash(set_name, definition)
55
+ references = {}
56
+ members = {}
57
+ properties = {}
58
+ default = nil
59
+
60
+ definition.each do |key, value|
61
+ if key == :default
62
+ if value.is_a?(Hash)
63
+ raise ArgumentError,
64
+ "Default for trait #{set_name.inspect} must name a member, not define styles"
65
+ end
66
+ default = value
67
+ elsif @conditions.breakpoint?(key) && value.is_a?(Hash)
68
+ raise ArgumentError,
69
+ "#{key.inspect} in trait #{set_name.inspect} is a configured breakpoint, so it cannot name a " \
70
+ "member. Conditions must be nested under a property, and members need a name that is not a breakpoint."
71
+ elsif reference_key?(key)
72
+ references[key] = value
73
+ elsif member_key?(key, value)
74
+ members[key] = value
75
+ else
76
+ properties[key] = value
77
+ end
78
+ end
79
+
80
+ if members.any? && (references.any? || properties.any?)
81
+ raise ArgumentError,
82
+ "Trait #{set_name.inspect} mixes enumerated members #{members.keys.inspect} with " \
83
+ "#{(references.keys + properties.keys).inspect}. A trait is either a list of members or a style."
84
+ end
85
+ if default && members.empty?
86
+ raise ArgumentError, "Trait #{set_name.inspect} has a default but no members"
87
+ end
88
+
89
+ if members.any?
90
+ Result.new(kind: :enumerated, members: members, default: default)
91
+ elsif references.any?
92
+ Result.new(kind: :combination, references: references, properties: properties)
93
+ else
94
+ Result.new(kind: :plain, properties: definition)
95
+ end
96
+ end
97
+
98
+ def reference_key?(key)
99
+ key.is_a?(Symbol) && (@sets.key?(key) || @inherited_sets.key?(key))
100
+ end
101
+
102
+ def member_key?(key, value)
103
+ return false unless key.is_a?(Symbol) && value.is_a?(Hash)
104
+ return false if Property.known?(Property.normalize_key(key))
105
+ return false if @conditions.condition?(key)
106
+
107
+ true
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Vars
6
+ attr_reader :id, :names
7
+
8
+ def initialize(id, definitions)
9
+ self.class.validate_definitions!(definitions)
10
+ @id = Immutable.copy(id)
11
+ @names = {}
12
+ @refs = {}
13
+
14
+ Atomix.registry.transaction do
15
+ definitions.each do |key, value|
16
+ var = Hashing.var_name(id, key)
17
+ @names[key.to_sym] = var
18
+ @refs[key.to_sym] = "var(#{var})"
19
+ Atomix.registry.replace_root(var)
20
+ Vars.register(":root", var, value)
21
+ end
22
+ end
23
+
24
+ @names = Immutable.copy(@names)
25
+ @refs = Immutable.copy(@refs)
26
+ freeze
27
+ end
28
+
29
+ def [](key)
30
+ @refs.fetch(key.to_sym) do
31
+ raise KeyError, "Unknown var #{key.inspect}. Available vars: #{@refs.keys.inspect}"
32
+ end
33
+ end
34
+
35
+ def var_name(key)
36
+ @names.fetch(key.to_sym)
37
+ end
38
+
39
+ def to_h
40
+ @refs
41
+ end
42
+
43
+ def method_missing(name, *args)
44
+ return super unless @refs.key?(name) && args.empty?
45
+
46
+ @refs[name]
47
+ end
48
+
49
+ def respond_to_missing?(name, include_private = false)
50
+ @refs.key?(name) || super
51
+ end
52
+
53
+ def theme(**overrides)
54
+ Atomix.create_theme(self, **overrides)
55
+ end
56
+
57
+ def self.validate_definitions!(definitions)
58
+ unless definitions.is_a?(Hash)
59
+ raise ArgumentError, "Variable definitions must be a Hash, got #{definitions.class}"
60
+ end
61
+
62
+ definitions.each_key do |key|
63
+ next unless method_defined?(key.to_sym) || Sheet.method_defined?(key.to_sym)
64
+
65
+ raise ArgumentError, "Variable #{key.inspect} collides with a method on Vars or the styles sheet. Pick another name."
66
+ end
67
+ end
68
+
69
+ def self.register(selector, var, value, conditions = [])
70
+ return if value.nil?
71
+
72
+ case value
73
+ when Hash
74
+ value.each do |key, nested|
75
+ next if nested.nil?
76
+
77
+ if key == :default
78
+ register(selector, var, nested, conditions)
79
+ else
80
+ register(selector, var, nested, conditions + [Atomix.config.resolve_condition(key)])
81
+ end
82
+ end
83
+ else
84
+ Atomix.registry.add(
85
+ Rule.new(
86
+ selector: selector,
87
+ property: var,
88
+ value: Property.normalize_value(var, value),
89
+ conditions: conditions,
90
+ priority: conditions.sum(0) { |c| Property.condition_priority(c) }
91
+ )
92
+ )
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weakref"
4
+
5
+ module ViewComponent
6
+ module Atomix
7
+ class WeakCollection
8
+ include Enumerable
9
+
10
+ def initialize
11
+ @references = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def <<(object)
16
+ @mutex.synchronize do
17
+ compact!
18
+ @references << WeakRef.new(object) unless @references.any? { |reference| dereference(reference)&.equal?(object) }
19
+ end
20
+ self
21
+ end
22
+
23
+ def each
24
+ return enum_for(:each) unless block_given?
25
+
26
+ objects = @mutex.synchronize do
27
+ compact!
28
+ @references.filter_map { |reference| dereference(reference) }
29
+ end
30
+ objects.each { |object| yield object }
31
+ self
32
+ end
33
+
34
+ def delete_if
35
+ @mutex.synchronize do
36
+ @references.delete_if do |reference|
37
+ object = dereference(reference)
38
+ object.nil? || yield(object)
39
+ end
40
+ end
41
+ self
42
+ end
43
+
44
+ def clear
45
+ @mutex.synchronize { @references.clear }
46
+ self
47
+ end
48
+
49
+ private
50
+
51
+ def compact!
52
+ @references.select!(&:weakref_alive?)
53
+ end
54
+
55
+ def dereference(reference)
56
+ reference.__getobj__
57
+ rescue WeakRef::RefError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end