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,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module Property
6
+ KNOWN = %w[
7
+ accent-color align-content align-items align-self alignment-baseline all anchor-name anchor-scope
8
+ animation animation-composition animation-delay animation-direction animation-duration
9
+ animation-fill-mode animation-iteration-count animation-name animation-play-state
10
+ animation-range animation-range-end animation-range-start animation-timeline
11
+ animation-timing-function appearance aspect-ratio backdrop-filter backface-visibility
12
+ background background-attachment background-blend-mode background-clip background-color
13
+ background-image background-origin background-position background-position-x
14
+ background-position-y background-repeat background-size baseline-shift baseline-source
15
+ block-size border border-block border-block-color border-block-end border-block-end-color
16
+ border-block-end-style border-block-end-width border-block-start border-block-start-color
17
+ border-block-start-style border-block-start-width border-block-style border-block-width
18
+ border-bottom border-bottom-color border-bottom-left-radius border-bottom-right-radius
19
+ border-bottom-style border-bottom-width border-collapse border-color border-end-end-radius
20
+ border-end-start-radius border-image border-image-outset border-image-repeat
21
+ border-image-slice border-image-source border-image-width border-inline border-inline-color
22
+ border-inline-end border-inline-end-color border-inline-end-style border-inline-end-width
23
+ border-inline-start border-inline-start-color border-inline-start-style
24
+ border-inline-start-width border-inline-style border-inline-width border-left
25
+ border-left-color border-left-style border-left-width border-radius border-right
26
+ border-right-color border-right-style border-right-width border-spacing
27
+ border-start-end-radius border-start-start-radius border-style border-top border-top-color
28
+ border-top-left-radius border-top-right-radius border-top-style border-top-width
29
+ border-width bottom box-decoration-break box-shadow box-sizing break-after break-before
30
+ break-inside caption-side caret caret-color caret-shape clear clip clip-path clip-rule
31
+ color color-interpolation color-interpolation-filters color-scheme column-count column-fill
32
+ column-gap column-rule column-rule-color column-rule-style column-rule-width column-span
33
+ column-width columns contain contain-intrinsic-block-size contain-intrinsic-height
34
+ contain-intrinsic-inline-size contain-intrinsic-size contain-intrinsic-width container
35
+ container-name container-type content content-visibility corner-shape counter-increment
36
+ counter-reset counter-set cursor direction display dominant-baseline empty-cells
37
+ field-sizing fill fill-opacity fill-rule filter flex flex-basis flex-direction flex-flow
38
+ flex-grow flex-shrink flex-wrap float flood-color flood-opacity font font-family
39
+ font-feature-settings font-kerning font-language-override font-optical-sizing font-palette
40
+ font-size font-size-adjust font-stretch font-style font-synthesis font-synthesis-small-caps
41
+ font-synthesis-style font-synthesis-weight font-variant font-variant-alternates
42
+ font-variant-caps font-variant-east-asian font-variant-emoji font-variant-ligatures
43
+ font-variant-numeric font-variant-position font-variation-settings font-weight
44
+ forced-color-adjust gap grid grid-area grid-auto-columns grid-auto-flow grid-auto-rows
45
+ grid-column grid-column-end grid-column-gap grid-column-start grid-gap grid-row grid-row-end
46
+ grid-row-gap grid-row-start grid-template grid-template-areas grid-template-columns
47
+ grid-template-rows hanging-punctuation height hyphenate-character hyphenate-limit-chars
48
+ hyphens image-orientation image-rendering initial-letter inline-size inset inset-block
49
+ inset-block-end inset-block-start inset-inline inset-inline-end inset-inline-start
50
+ interpolate-size isolation justify-content justify-items justify-self left letter-spacing
51
+ lighting-color line-break line-clamp line-height list-style list-style-image
52
+ list-style-position list-style-type margin margin-block margin-block-end margin-block-start
53
+ margin-bottom margin-inline margin-inline-end margin-inline-start margin-left margin-right
54
+ margin-top margin-trim marker marker-end marker-mid marker-start mask mask-border
55
+ mask-border-mode mask-border-outset mask-border-repeat mask-border-slice mask-border-source
56
+ mask-border-width mask-clip mask-composite mask-image mask-mode mask-origin mask-position
57
+ mask-repeat mask-size mask-type math-depth math-shift math-style max-block-size max-height
58
+ max-inline-size max-width min-block-size min-height min-inline-size min-width mix-blend-mode
59
+ object-fit object-position offset offset-anchor offset-distance offset-path offset-position
60
+ offset-rotate opacity order orphans outline outline-color outline-offset outline-style
61
+ outline-width overflow overflow-anchor overflow-block overflow-clip-margin overflow-inline
62
+ overflow-wrap overflow-x overflow-y overlay overscroll-behavior overscroll-behavior-block
63
+ overscroll-behavior-inline overscroll-behavior-x overscroll-behavior-y padding padding-block
64
+ padding-block-end padding-block-start padding-bottom padding-inline padding-inline-end
65
+ padding-inline-start padding-left padding-right padding-top page page-break-after
66
+ page-break-before page-break-inside paint-order perspective perspective-origin place-content
67
+ place-items place-self pointer-events position position-anchor position-area position-try
68
+ position-try-fallbacks position-try-order position-visibility print-color-adjust quotes
69
+ resize right rotate row-gap ruby-align ruby-position scale scroll-behavior
70
+ scroll-margin scroll-margin-block scroll-margin-block-end scroll-margin-block-start
71
+ scroll-margin-bottom scroll-margin-inline scroll-margin-inline-end scroll-margin-inline-start
72
+ scroll-margin-left scroll-margin-right scroll-margin-top scroll-padding scroll-padding-block
73
+ scroll-padding-block-end scroll-padding-block-start scroll-padding-bottom
74
+ scroll-padding-inline scroll-padding-inline-end scroll-padding-inline-start
75
+ scroll-padding-left scroll-padding-right scroll-padding-top scroll-snap-align
76
+ scroll-snap-stop scroll-snap-type scroll-timeline scroll-timeline-axis scroll-timeline-name
77
+ scrollbar-color scrollbar-gutter scrollbar-width shape-image-threshold shape-margin
78
+ shape-outside shape-rendering stop-color stop-opacity stroke stroke-dasharray
79
+ stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity
80
+ stroke-width tab-size table-layout text-align text-align-last text-anchor text-autospace
81
+ text-box text-box-edge text-box-trim text-combine-upright text-decoration
82
+ text-decoration-color text-decoration-line text-decoration-skip text-decoration-skip-ink
83
+ text-decoration-style text-decoration-thickness text-emphasis text-emphasis-color
84
+ text-emphasis-position text-emphasis-style text-indent text-justify text-orientation
85
+ text-overflow text-rendering text-shadow text-size-adjust text-spacing-trim text-transform
86
+ text-underline-offset text-underline-position text-wrap text-wrap-mode text-wrap-style top
87
+ touch-action transform transform-box transform-origin transform-style transition
88
+ transition-behavior transition-delay transition-duration transition-property
89
+ transition-timing-function translate unicode-bidi user-select vector-effect vertical-align
90
+ view-timeline view-timeline-axis view-timeline-inset view-timeline-name view-transition-class
91
+ view-transition-name visibility white-space white-space-collapse widows width will-change
92
+ word-break word-spacing word-wrap writing-mode z-index zoom
93
+ ].to_set.freeze
94
+
95
+ module_function
96
+
97
+ def known?(property)
98
+ KNOWN.include?(property) || property.start_with?("--", "-webkit-", "-moz-", "-ms-", "-o-")
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ # Resolution state is resettable. Installed accessor names and mixin
6
+ # metadata belong to Accessors and survive resets. Unload is terminal for
7
+ # this owner; Rails creates a new owner when it loads the constant again.
8
+ class OwnerState
9
+ attr_accessor :styles_own, :styles_resolved, :styles_breakpoints, :styles_resolving, :variables_resolving,
10
+ :theme_own, :theme_resolved, :theme_resolving, :companion_styles_module
11
+ attr_reader :styles, :variables, :themes
12
+
13
+ def initialize
14
+ clear
15
+ end
16
+
17
+ def reset!
18
+ ResolutionSession.stale!
19
+ clear
20
+ end
21
+
22
+ def unload!
23
+ @unloaded = true
24
+ invalidate!
25
+ end
26
+
27
+ def unloaded?
28
+ @unloaded == true
29
+ end
30
+
31
+ def invalidate!
32
+ ResolutionSession.stale!
33
+ @styles_resolved = @theme_resolved = nil
34
+ end
35
+
36
+ private
37
+
38
+ def clear
39
+ @styles_own = @styles_resolved = @styles_breakpoints = nil
40
+ @styles_resolving = @variables_resolving = @theme_resolving = false
41
+ @theme_own = @theme_resolved = @companion_styles_module = nil
42
+ @styles = DeclarationQueue.new
43
+ @variables = DeclarationQueue.new
44
+ @themes = DeclarationQueue.new
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ module Property
6
+ SHORTHANDS_OF_SHORTHANDS = %w[
7
+ animation background border font grid grid-area
8
+ inset margin mask padding scroll-margin scroll-padding
9
+ ].to_set.freeze
10
+
11
+ TIME_PROPERTIES = %w[
12
+ animation-delay animation-duration transition-delay transition-duration voice-duration
13
+ ].to_set.freeze
14
+
15
+ ADDITIONAL_UNITLESS = %w[
16
+ -webkit-line-clamp counter-set counter-reset hyphenate-limit-chars
17
+ mask-border-outset mask-border-slice mask-border-width rotate
18
+ shape-image-threshold math-depth
19
+ ].to_set.freeze
20
+
21
+ # These shorthands sit between their parent shorthand and other shorthands.
22
+ INTERMEDIATE_SHORTHANDS = %w[border-block border-inline grid-template].to_set.freeze
23
+
24
+ SHORTHANDS_OF_LONGHANDS = %w[
25
+ animation-range background-position border-block-color border-block-end border-block-start
26
+ border-block-style border-block-width border-bottom border-color
27
+ border-image border-inline-color border-inline-end border-inline-start border-inline-style
28
+ border-inline-width border-left border-radius border-right border-style border-top border-width
29
+ caret column-rule columns
30
+ contain-intrinsic-size container corner-shape flex flex-flow font-synthesis font-variant gap grid-column
31
+ grid-gap grid-row inset-block inset-inline list-style margin-block
32
+ margin-inline mask-border offset outline overflow overscroll-behavior padding-block
33
+ padding-inline place-content place-items place-self position-try scroll-margin-block scroll-margin-inline
34
+ scroll-padding-block scroll-padding-inline scroll-timeline text-box text-decoration
35
+ text-emphasis text-wrap transition view-timeline white-space
36
+ ].to_set.freeze
37
+
38
+ UNITLESS = %w[
39
+ animation-iteration-count aspect-ratio border-image-outset border-image-slice
40
+ border-image-width box-flex box-flex-group box-ordinal-group column-count columns fill-opacity
41
+ flex flex-grow flex-negative flex-order flex-positive flex-shrink flood-opacity font-weight
42
+ grid-area grid-column grid-column-end grid-column-span grid-column-start grid-row
43
+ grid-row-end grid-row-span grid-row-start line-clamp line-height opacity order orphans
44
+ scale stop-opacity stroke-dasharray stroke-dashoffset stroke-miterlimit stroke-opacity
45
+ stroke-width tab-size widows z-index zoom
46
+ ].to_set.freeze
47
+
48
+ AT_RULE_PRIORITIES = {
49
+ "@supports" => 30,
50
+ "@media" => 200,
51
+ "@container" => 300
52
+ }.freeze
53
+
54
+ PSEUDO_CLASS_PRIORITIES = {
55
+ ":is" => 40, ":where" => 40, ":not" => 40, ":has" => 45, ":dir" => 50, ":lang" => 51,
56
+ ":first-child" => 52, ":first-of-type" => 53, ":last-child" => 54, ":last-of-type" => 55,
57
+ ":only-child" => 56, ":only-of-type" => 57, ":nth-child" => 60, ":nth-last-child" => 61,
58
+ ":nth-of-type" => 62, ":nth-last-of-type" => 63, ":empty" => 70, ":link" => 80,
59
+ ":any-link" => 81, ":local-link" => 82, ":target-within" => 83, ":target" => 84,
60
+ ":visited" => 85, ":enabled" => 91, ":disabled" => 92, ":required" => 93, ":optional" => 94,
61
+ ":read-only" => 95, ":read-write" => 96, ":placeholder-shown" => 97, ":in-range" => 98,
62
+ ":out-of-range" => 99, ":default" => 100, ":checked" => 101, ":indeterminate" => 101,
63
+ ":blank" => 102, ":valid" => 103, ":invalid" => 104, ":user-invalid" => 105,
64
+ ":autofill" => 110, ":picture-in-picture" => 120, ":modal" => 121, ":fullscreen" => 122,
65
+ ":paused" => 123, ":playing" => 124, ":current" => 125, ":past" => 126, ":future" => 127,
66
+ ":hover" => 130, ":focus-within" => 140, ":focus" => 150, ":focus-visible" => 160,
67
+ ":active" => 170
68
+ }.freeze
69
+
70
+ DEFAULT_PSEUDO_CLASS_PRIORITY = 40
71
+ PSEUDO_ELEMENT_PRIORITY = 5000
72
+ LOGICAL_PATTERN = /(^|-)(block|inline|start|end)(-|$)/
73
+
74
+ STRUCTURAL_CHARACTERS = %r{[;{}()\[\]"'\\]|/\*}
75
+ PLAIN_TEXT = %r{[^;{}()\[\]"'\\/]+}
76
+ ESCAPE = /\\(?:\h{1,6}(?:\r\n|[ \t\n\r\f])?|.)/m
77
+ DOUBLE_QUOTED = /"(?:[^"\\\n\r\f]|\\.)*"/m
78
+ SINGLE_QUOTED = /'(?:[^'\\\n\r\f]|\\.)*'/m
79
+ COMMENT = %r{/\*.*?\*/}m
80
+ NAME = /\A(?:[a-zA-Z0-9_\-\x00]|[^\x00-\x7F])*\z/
81
+ URL_NAME = /(?<![a-zA-Z0-9_\-\x00]|[^\x00-\x7F])url\z/i
82
+ SPLIT_URL_NAME = /(?:[#@]|<!--)url\z/i
83
+ QUOTED_URL = /\([ \t\n\r\f]*["']/
84
+ UNQUOTED_URL = /\([ \t\n\r\f]*(?:[^"'()\\\x00-\x20\x7F]|\\[^\n\r\f])*[ \t\n\r\f]*\)/
85
+ CLOSERS = { "(" => ")", "[" => "]", "{" => "}" }.freeze
86
+
87
+ module_function
88
+
89
+ def normalize_key(key)
90
+ name = key.to_s
91
+ return name if name.start_with?("--")
92
+
93
+ name.gsub(/([a-z\d])([A-Z])/, '\1-\2').tr("_", "-").downcase
94
+ end
95
+
96
+ def normalize_value(property, value)
97
+ case value
98
+ when Array then value.map { |v| normalize_value(property, v) }
99
+ when Integer, Float
100
+ return value.to_s if property.start_with?("--") || ADDITIONAL_UNITLESS.include?(property)
101
+ return "#{value}ms" if TIME_PROPERTIES.include?(property)
102
+ return "0" if value.zero?
103
+ UNITLESS.include?(property) ? value.to_s : "#{value}px"
104
+ when Symbol then validate_value!(property, value.to_s.tr("_", "-"))
105
+ else validate_value!(property, value.to_s)
106
+ end
107
+ end
108
+
109
+ def validate_value!(property, value)
110
+ return value unless value.match?(STRUCTURAL_CHARACTERS)
111
+ return value if self_contained?(value)
112
+
113
+ raise ArgumentError,
114
+ "Unsafe CSS value for #{property}: #{value.inspect}. A value cannot contain a top-level ';' " \
115
+ "or unbalanced quotes, brackets or comments."
116
+ end
117
+
118
+ def self_contained?(value)
119
+ open = []
120
+ scanner = StringScanner.new(value)
121
+ word = nil
122
+ escaped = false
123
+ until scanner.eos?
124
+ if (text = scanner.scan(PLAIN_TEXT))
125
+ word = text
126
+ next
127
+ end
128
+ if scanner.skip(ESCAPE)
129
+ word = nil
130
+ escaped = true
131
+ next
132
+ end
133
+
134
+ url = scanner.match?(/\(/) && url_token(scanner, word.to_s, escaped)
135
+ word = nil
136
+ escaped = false
137
+ return false if url == :malformed
138
+ next if url == :consumed || scanner.skip(DOUBLE_QUOTED) || scanner.skip(SINGLE_QUOTED) || scanner.skip(COMMENT)
139
+
140
+ if scanner.match?(%r{/\*}) then return false
141
+ elsif scanner.skip(%r{/}) then next
142
+ elsif (bracket = scanner.scan(/[(\[{]/)) then open << CLOSERS.fetch(bracket)
143
+ elsif (bracket = scanner.scan(/[)\]}]/)) then return false unless open.pop == bracket
144
+ elsif scanner.skip(/;/) then return false if open.empty?
145
+ else return false
146
+ end
147
+ end
148
+ open.empty?
149
+ end
150
+
151
+ def url_token(scanner, name, escaped)
152
+ return :malformed if (escaped && name.match?(NAME)) || name.match?(SPLIT_URL_NAME)
153
+ return unless name.match?(URL_NAME) && !scanner.match?(QUOTED_URL)
154
+
155
+ scanner.skip(UNQUOTED_URL) ? :consumed : :malformed
156
+ end
157
+
158
+ def base_priority(property)
159
+ return 500 if property == "all"
160
+ return 1000 if SHORTHANDS_OF_SHORTHANDS.include?(property)
161
+ return 1500 if INTERMEDIATE_SHORTHANDS.include?(property)
162
+ return 2000 if SHORTHANDS_OF_LONGHANDS.include?(property)
163
+ return 3000 if property.match?(LOGICAL_PATTERN)
164
+
165
+ 4000
166
+ end
167
+
168
+ def condition_priority(condition)
169
+ if condition.start_with?("@")
170
+ AT_RULE_PRIORITIES.fetch(condition.split(/[\s(]/, 2).first, AT_RULE_PRIORITIES["@media"])
171
+ elsif condition.start_with?("::")
172
+ PSEUDO_ELEMENT_PRIORITY
173
+ else
174
+ PSEUDO_CLASS_PRIORITIES.fetch(condition.split("(", 2).first, DEFAULT_PSEUDO_CLASS_PRIORITY)
175
+ end
176
+ end
177
+
178
+ def priority(property, conditions = [])
179
+ conditions.sum(base_priority(property)) { |c| condition_priority(c) }
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module ViewComponent
6
+ module Atomix
7
+ class Railtie < Rails::Railtie
8
+ config.before_configuration do |app|
9
+ app.config.view_component.atomix = ViewComponent::Atomix.config
10
+ ViewComponent::Atomix.config.inline = !Rails.env.production?
11
+ end
12
+
13
+ initializer "view_component.atomix.autoload_roots", before: :set_load_path do |app|
14
+ ViewComponent::Atomix.config.autoload_roots.each do |relative|
15
+ app.config.paths.add relative.to_s, eager_load: true
16
+ dir = app.root.join(relative.to_s).to_s
17
+ next unless File.directory?(dir)
18
+
19
+ effective = app.config.respond_to?(:all_eager_load_paths) ? app.config.all_eager_load_paths : app.config.eager_load_paths
20
+ app.config.eager_load_paths << dir unless effective.include?(dir)
21
+ end
22
+ end
23
+
24
+ initializer "view_component.atomix.helper" do
25
+ ActiveSupport.on_load(:action_view) { include ViewComponent::Atomix::Helper }
26
+ end
27
+
28
+ initializer "view_component.atomix.reloader" do
29
+ Rails.autoloaders.main.on_unload do |_name, owner, source|
30
+ ViewComponent::Atomix.unload!(owner, source)
31
+ end
32
+ ActiveSupport::Reloader.before_class_unload do
33
+ ViewComponent::Atomix.registry.clear_reloadable
34
+ end
35
+ end
36
+
37
+ config.to_prepare do
38
+ ViewComponent::Atomix.eager_load! if ViewComponent::Atomix.config.inline?
39
+ end
40
+
41
+ rake_tasks do
42
+ load File.expand_path("tasks/build.rake", __dir__)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ class Registry
6
+ Registration = Struct.new(:rule, :source, keyword_init: true)
7
+ RootReplacement = Struct.new(:property, :source, keyword_init: true)
8
+ Transaction = Struct.new(:registrations, :callbacks, keyword_init: true) do
9
+ def initialize(registrations: [], callbacks: [])
10
+ super
11
+ end
12
+
13
+ def absorb(other)
14
+ registrations.concat(other.registrations)
15
+ callbacks.concat(other.callbacks)
16
+ end
17
+ end
18
+
19
+ def initialize
20
+ @mutex = Mutex.new
21
+ @rules = {}
22
+ @root_rules = {}
23
+ @css = nil
24
+ end
25
+
26
+ def add(rule)
27
+ entry = Registration.new(rule: rule, source: registration_source)
28
+ if (pending = transactions.last)
29
+ pending.registrations << entry
30
+ else
31
+ commit(Transaction.new(registrations: [entry]))
32
+ end
33
+ rule
34
+ end
35
+
36
+ def after_commit(&block)
37
+ if (pending = transactions.last)
38
+ pending.callbacks << block
39
+ else
40
+ block.call
41
+ end
42
+ end
43
+
44
+ # Record the replacement in declaration order, in the same transaction as
45
+ # its new rules. Empty assignments must also remove the old branches.
46
+ def replace_root(property)
47
+ entry = RootReplacement.new(property: property, source: registration_source)
48
+ if (pending = transactions.last)
49
+ pending.registrations << entry
50
+ else
51
+ commit(Transaction.new(registrations: [entry]))
52
+ end
53
+ end
54
+
55
+ # Nested operations join their declaration. A separately resolved owner
56
+ # uses independent: true because its successful result is cached even if
57
+ # the declaration which requested it subsequently fails.
58
+ def transaction(independent: false)
59
+ pending = Transaction.new
60
+ transactions << pending
61
+ result = yield
62
+ if transactions.length > 1 && !independent
63
+ transactions[-2].absorb(pending)
64
+ else
65
+ commit(pending)
66
+ end
67
+ result
68
+ ensure
69
+ stack = transactions
70
+ stack.pop
71
+ Thread.current[:view_component_atomix_transactions].delete(object_id) if stack.empty?
72
+ end
73
+
74
+ def with_source(path)
75
+ previous = Thread.current[:view_component_atomix_source]
76
+ Thread.current[:view_component_atomix_source] = path
77
+ yield
78
+ ensure
79
+ Thread.current[:view_component_atomix_source] = previous
80
+ end
81
+
82
+ def clear_reloadable
83
+ roots = reloadable_roots
84
+ @mutex.synchronize do
85
+ @rules.delete_if do |_, sources|
86
+ sources.delete_if { |source| source && roots.any? { |root| source.start_with?(root) } }
87
+ sources.empty?
88
+ end
89
+ @root_rules.each_value { |rules| rules.select! { |rule| @rules.key?(rule) } }
90
+ @root_rules.delete_if { |_, rules| rules.empty? }
91
+ @css = nil
92
+ end
93
+ end
94
+
95
+ def clear
96
+ @mutex.synchronize do
97
+ @rules.clear
98
+ @root_rules.clear
99
+ @css = nil
100
+ end
101
+ end
102
+
103
+ def rules
104
+ @mutex.synchronize { @rules.keys }
105
+ end
106
+
107
+ def size
108
+ @mutex.synchronize { @rules.size }
109
+ end
110
+
111
+ def to_css
112
+ @mutex.synchronize do
113
+ @css ||= build_css(@rules.keys)
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ def transactions
120
+ Thread.current[:view_component_atomix_transactions] ||= {}
121
+ Thread.current[:view_component_atomix_transactions][object_id] ||= []
122
+ end
123
+
124
+ def commit(transaction)
125
+ @mutex.synchronize do
126
+ transaction.registrations.each do |entry|
127
+ if entry.is_a?(RootReplacement)
128
+ release_root(entry.property, entry.source)
129
+ else
130
+ register(entry.rule, entry.source)
131
+ end
132
+ end
133
+ @css = nil unless transaction.registrations.empty?
134
+ end
135
+ transaction.callbacks.each(&:call)
136
+ end
137
+
138
+ def register(rule, source)
139
+ unless rule.selector == ":root"
140
+ (@rules[rule] ||= Set.new).add(source)
141
+ return
142
+ end
143
+
144
+ release_root(rule.property, source) { |existing| existing.conditions == rule.conditions }
145
+ @rules[rule] = (@rules.delete(rule) || Set.new).add(source)
146
+ (@root_rules[rule.property] ||= Set.new).add(rule)
147
+ end
148
+
149
+ # Root variables are assignments, unlike content-addressed atoms. Remove
150
+ # only this source's previous assignment; other owners may still use it.
151
+ def release_root(property, source)
152
+ rules = @root_rules[property]
153
+ return unless rules
154
+
155
+ rules.delete_if do |rule|
156
+ next false if block_given? && !yield(rule)
157
+
158
+ sources = @rules.fetch(rule)
159
+ sources.delete(source)
160
+ next false unless sources.empty?
161
+
162
+ @rules.delete(rule)
163
+ true
164
+ end
165
+ @root_rules.delete(property) if rules.empty?
166
+ end
167
+
168
+ def reloadable_roots
169
+ return [] unless defined?(Rails) && Rails.respond_to?(:autoloaders) && Rails.application
170
+
171
+ Rails.autoloaders.main.dirs.map { |dir| "#{File.expand_path(dir)}/" }
172
+ end
173
+
174
+ def registration_source
175
+ source = Thread.current[:view_component_atomix_source]
176
+ return File.expand_path(source) if source
177
+
178
+ roots = reloadable_roots
179
+ caller_locations.each do |location|
180
+ path = File.expand_path(location.absolute_path || location.path)
181
+ return path if roots.any? { |root| path.start_with?(root) }
182
+ end
183
+ nil
184
+ end
185
+
186
+ def build_css(rules)
187
+ sorted = rules.sort_by.with_index { |rule, i| [rule.priority, i] }
188
+
189
+ unless Atomix.config.use_layers
190
+ return sorted.map(&:to_css).join("\n")
191
+ end
192
+
193
+ groups = sorted.group_by(&:priority)
194
+ return "" if groups.empty?
195
+
196
+ header = "@layer #{groups.keys.map { |p| layer_name(p) }.join(',')};"
197
+ body = groups.map do |priority, group|
198
+ "@layer #{layer_name(priority)}{\n#{group.map(&:to_css).join("\n")}\n}"
199
+ end
200
+ [header, *body].join("\n")
201
+ end
202
+
203
+ def layer_name(priority)
204
+ "#{Atomix.config.class_prefix}#{priority}"
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module Atomix
5
+ # One logical read, including nested owners, queue drains and cache rebuilds.
6
+ # Each frame tracks declarations made during its work (stale) and reads of
7
+ # work that has not finished yet (provisional). Neither may publish a cache.
8
+ class ResolutionSession
9
+ MAX_DEPTH = 64
10
+ MAX_STABILITY_PASSES = 32
11
+ Frame = Struct.new(:provisional, :stale)
12
+
13
+ def self.with
14
+ existing = Thread.current[:view_component_atomix_resolution]
15
+ return yield existing if existing
16
+
17
+ begin
18
+ session = new
19
+ Thread.current[:view_component_atomix_resolution] = session
20
+ yield session
21
+ ensure
22
+ Thread.current[:view_component_atomix_resolution] = nil
23
+ end
24
+ end
25
+
26
+ def self.stale!
27
+ Thread.current[:view_component_atomix_resolution]&.stale!
28
+ end
29
+
30
+ def initialize
31
+ @frames = []
32
+ @depth = 0
33
+ @passes = 0
34
+ end
35
+
36
+ def resolving?
37
+ !@frames.empty?
38
+ end
39
+
40
+ def provisional!
41
+ @frames.each { |frame| frame.provisional = true }
42
+ end
43
+
44
+ def stale!
45
+ @frames.each { |frame| frame.stale = true }
46
+ end
47
+
48
+ def frame
49
+ nested do
50
+ current = Frame.new(false, false)
51
+ @frames << current
52
+ begin
53
+ yield current
54
+ ensure
55
+ @frames.pop
56
+ end
57
+ end
58
+ end
59
+
60
+ def nested
61
+ if @depth >= MAX_DEPTH
62
+ raise ArgumentError, "Style resolution exceeds the nesting limit (#{MAX_DEPTH})"
63
+ end
64
+
65
+ @depth += 1
66
+ begin
67
+ yield
68
+ ensure
69
+ @depth -= 1
70
+ end
71
+ end
72
+
73
+ def consume!
74
+ @passes += 1
75
+ if @passes > DeclarationQueue::MAX_DRAIN_PASSES
76
+ raise ArgumentError, "Style declarations keep adding work during resolution"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end