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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_PSEUDO_MAP = {
|
|
7
|
+
hover: ":hover",
|
|
8
|
+
focus: ":focus",
|
|
9
|
+
focus_visible: ":focus-visible",
|
|
10
|
+
focus_within: ":focus-within",
|
|
11
|
+
active: ":active",
|
|
12
|
+
visited: ":visited",
|
|
13
|
+
link: ":link",
|
|
14
|
+
target: ":target",
|
|
15
|
+
disabled: ":disabled",
|
|
16
|
+
enabled: ":enabled",
|
|
17
|
+
checked: ":checked",
|
|
18
|
+
indeterminate: ":indeterminate",
|
|
19
|
+
required: ":required",
|
|
20
|
+
optional: ":optional",
|
|
21
|
+
valid: ":valid",
|
|
22
|
+
invalid: ":invalid",
|
|
23
|
+
read_only: ":read-only",
|
|
24
|
+
placeholder_shown: ":placeholder-shown",
|
|
25
|
+
empty: ":empty",
|
|
26
|
+
first_child: ":first-child",
|
|
27
|
+
last_child: ":last-child",
|
|
28
|
+
only_child: ":only-child",
|
|
29
|
+
first_of_type: ":first-of-type",
|
|
30
|
+
last_of_type: ":last-of-type",
|
|
31
|
+
before: "::before",
|
|
32
|
+
after: "::after",
|
|
33
|
+
placeholder: "::placeholder",
|
|
34
|
+
selection: "::selection",
|
|
35
|
+
marker: "::marker",
|
|
36
|
+
dark: "@media (prefers-color-scheme: dark)",
|
|
37
|
+
light: "@media (prefers-color-scheme: light)",
|
|
38
|
+
reduced_motion: "@media (prefers-reduced-motion: reduce)",
|
|
39
|
+
print: "@media print"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
CLASS_PREFIX = /\A[a-zA-Z_][a-zA-Z0-9_-]*\z/
|
|
43
|
+
|
|
44
|
+
attr_accessor :vars, :eager_load_paths, :autoload_roots, :use_layers, :inline, :output_path
|
|
45
|
+
attr_reader :styles_method, :breakpoints, :class_prefix, :pseudo_map
|
|
46
|
+
alias inline? inline
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
reset!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset!
|
|
53
|
+
@class_prefix = "vc"
|
|
54
|
+
@breakpoints = {}
|
|
55
|
+
@pseudo_map = DEFAULT_PSEUDO_MAP.dup
|
|
56
|
+
@output_path = "app/assets/builds/view_component_atomix.css"
|
|
57
|
+
@vars = nil
|
|
58
|
+
@eager_load_paths = ["app/components", "app/components/styles", "app/styles"]
|
|
59
|
+
@autoload_roots = ["app/components/styles"]
|
|
60
|
+
@use_layers = false
|
|
61
|
+
@inline = true
|
|
62
|
+
@styles_method = :styles
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def styles_method=(name)
|
|
66
|
+
@styles_method = name.to_sym
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def class_prefix=(prefix)
|
|
70
|
+
prefix = prefix.to_s
|
|
71
|
+
unless prefix.match?(CLASS_PREFIX)
|
|
72
|
+
raise ArgumentError, "class_prefix must start with a letter or underscore and contain only " \
|
|
73
|
+
"letters, digits, hyphens and underscores, got #{prefix.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@class_prefix = prefix
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pseudo_map=(map)
|
|
80
|
+
ConditionResolver.validate_pseudo_map!(map)
|
|
81
|
+
ConditionResolver.validate_breakpoints!(breakpoints, pseudo_map: map)
|
|
82
|
+
@pseudo_map = map.dup
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def breakpoints=(map)
|
|
86
|
+
validate_breakpoints!(map)
|
|
87
|
+
@breakpoints = map.dup
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_breakpoints!(map)
|
|
91
|
+
ConditionResolver.validate_breakpoints!(map, pseudo_map: pseudo_map)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def condition_resolver(breakpoints = self.breakpoints)
|
|
95
|
+
ConditionResolver.new(breakpoints: breakpoints, pseudo_map: pseudo_map)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_condition(key, breakpoints = self.breakpoints)
|
|
99
|
+
condition_resolver(breakpoints).resolve(key)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
class DeclarationQueue
|
|
6
|
+
MAX_DRAIN_PASSES = 1000
|
|
7
|
+
StyleDeclaration = Struct.new(:block, :source, keyword_init: true)
|
|
8
|
+
VariableDeclaration = Struct.new(:label, :block, :source, keyword_init: true)
|
|
9
|
+
ThemeDeclaration = Struct.new(:vars, :overrides, :block, :source, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@pending = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def <<(declaration)
|
|
16
|
+
@pending << declaration
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Successful declarations stay consumed. On failure, retry the failed
|
|
21
|
+
# declaration and its successors before work enqueued during evaluation.
|
|
22
|
+
# Owners serialize access with the reentrant resolution monitor.
|
|
23
|
+
def drain
|
|
24
|
+
ResolutionSession.with do |session|
|
|
25
|
+
session.nested do
|
|
26
|
+
until @pending.empty?
|
|
27
|
+
session.consume!
|
|
28
|
+
drain_batch { |declaration| yield declaration }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def drain_batch
|
|
37
|
+
pending = @pending
|
|
38
|
+
@pending = []
|
|
39
|
+
pending.each_with_index do |declaration, index|
|
|
40
|
+
begin
|
|
41
|
+
yield declaration
|
|
42
|
+
rescue StandardError
|
|
43
|
+
@pending = pending.drop(index) + @pending
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module Atomix
|
|
7
|
+
module DynamicProperties
|
|
8
|
+
module LiteralKey
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read(node)
|
|
12
|
+
return unless node.is_a?(Array)
|
|
13
|
+
|
|
14
|
+
case node.first
|
|
15
|
+
when :@label
|
|
16
|
+
node[1].delete_suffix(":")
|
|
17
|
+
when :symbol_literal
|
|
18
|
+
token = find_token(node, :@ident) || find_token(node, :@op) || find_token(node, :@kw)
|
|
19
|
+
token&.[](1)
|
|
20
|
+
when :string_literal, :dyna_symbol
|
|
21
|
+
decode_literal_string(node)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def decode_literal_string(node)
|
|
26
|
+
contents = node[1].drop(1)
|
|
27
|
+
return unless text_tokens?(contents)
|
|
28
|
+
|
|
29
|
+
quote = node[2] || '"'
|
|
30
|
+
quote = quote.delete_prefix(":") if quote.start_with?(":")
|
|
31
|
+
quote = quote.sub(/\A%s/, "%q")
|
|
32
|
+
literal = reconstruct_string(quote, contents)
|
|
33
|
+
return unless literal
|
|
34
|
+
|
|
35
|
+
# Reparse immediately before evaluating: only one text-only string is
|
|
36
|
+
# allowed. Interpolation, calls, and extra expressions cannot pass.
|
|
37
|
+
expressions = Ripper.sexp(literal)&.[](1)
|
|
38
|
+
return unless expressions&.one? && expressions.first.first == :string_literal
|
|
39
|
+
return unless text_tokens?(expressions.first[1].drop(1))
|
|
40
|
+
|
|
41
|
+
eval(literal, TOPLEVEL_BINDING, __FILE__, __LINE__) # rubocop:disable Security/Eval
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reconstruct_string(quote, contents)
|
|
45
|
+
return unless ["'", '"'].include?(quote) || quote.match?(/\A%(?:q|Q)?.\z/)
|
|
46
|
+
|
|
47
|
+
closing = { "(" => ")", "[" => "]", "{" => "}", "<" => ">" }.fetch(quote[-1], quote[-1])
|
|
48
|
+
quote + contents.map { |part| part[1] }.join + closing
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def text_tokens?(contents)
|
|
52
|
+
contents.all? { |part| part.first == :@tstring_content }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_token(node, type)
|
|
56
|
+
return node if node.is_a?(Array) && node.first == type
|
|
57
|
+
return unless node.is_a?(Array)
|
|
58
|
+
|
|
59
|
+
node.each do |child|
|
|
60
|
+
found = find_token(child, type)
|
|
61
|
+
return found if found
|
|
62
|
+
end
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
module DynamicProperties
|
|
6
|
+
class SourceCache
|
|
7
|
+
LIMIT = 128
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@cache = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def fetch(path)
|
|
15
|
+
stat = File.stat(path)
|
|
16
|
+
cache_key = [stat.mtime.to_f, stat.size]
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
cached = @cache[path]
|
|
19
|
+
return cached.last if cached&.first == cache_key
|
|
20
|
+
|
|
21
|
+
parser = SourceParser.new(File.read(path), path)
|
|
22
|
+
parsed = parser.parse && parser.lambdas
|
|
23
|
+
@cache.shift if !@cache.key?(path) && @cache.size >= LIMIT
|
|
24
|
+
@cache[path] = [cache_key, parsed]
|
|
25
|
+
parsed
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module Atomix
|
|
7
|
+
module DynamicProperties
|
|
8
|
+
# Associate each lambda with its opening token while parsing, before
|
|
9
|
+
# modifier expressions can reorder nodes in the resulting syntax tree.
|
|
10
|
+
class SourceParser < Ripper::SexpBuilderPP
|
|
11
|
+
attr_reader :lambdas
|
|
12
|
+
|
|
13
|
+
def initialize(*)
|
|
14
|
+
super
|
|
15
|
+
@lambda_lines = []
|
|
16
|
+
@lambdas = {}
|
|
17
|
+
@string_quotes = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def on_heredoc_beg(token)
|
|
21
|
+
@string_quotes << token
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_tstring_beg(token)
|
|
26
|
+
@string_quotes << token
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_symbeg(token)
|
|
31
|
+
# Bare symbols have no string body; quoted and percent symbols do.
|
|
32
|
+
@string_quotes << token unless token == ":"
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def on_dyna_symbol(content)
|
|
37
|
+
node = super
|
|
38
|
+
node << @string_quotes.pop
|
|
39
|
+
node
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def on_string_literal(content)
|
|
43
|
+
node = super
|
|
44
|
+
node << @string_quotes.pop
|
|
45
|
+
node
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_tlambda(token)
|
|
49
|
+
@lambda_lines << lineno
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_lambda(params, body)
|
|
54
|
+
node = super
|
|
55
|
+
(@lambdas[@lambda_lines.pop] ||= []) << node
|
|
56
|
+
node
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dynamic_properties/source_parser"
|
|
4
|
+
require_relative "dynamic_properties/source_cache"
|
|
5
|
+
require_relative "dynamic_properties/literal_key"
|
|
6
|
+
|
|
7
|
+
module ViewComponent
|
|
8
|
+
module Atomix
|
|
9
|
+
module DynamicProperties
|
|
10
|
+
class Unsupported < ArgumentError; end
|
|
11
|
+
|
|
12
|
+
SOURCE_CACHE = SourceCache.new
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def extract(fn, name:, set:)
|
|
17
|
+
path, line = fn.source_location
|
|
18
|
+
raise unsupported(name, set, "source is unavailable") unless path && File.file?(path)
|
|
19
|
+
|
|
20
|
+
lambdas = SOURCE_CACHE.fetch(path)
|
|
21
|
+
raise unsupported(name, set, "source could not be parsed") unless lambdas
|
|
22
|
+
|
|
23
|
+
candidates = lambdas.fetch(line, [])
|
|
24
|
+
if candidates.length != 1
|
|
25
|
+
detail = candidates.empty? ? "lambda could not be located at #{path}:#{line}" :
|
|
26
|
+
"multiple lambdas start at #{path}:#{line}; put each on its own line"
|
|
27
|
+
raise unsupported(name, set, detail)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
properties_from(candidates.first, name, set)
|
|
31
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
32
|
+
raise unsupported(name, set, e.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def properties_from(lambda_node, name, set)
|
|
36
|
+
body = returned_hash(lambda_node, name, set)
|
|
37
|
+
hash_associations(body).map do |association|
|
|
38
|
+
unless association.is_a?(Array) && association.first == :assoc_new
|
|
39
|
+
raise unsupported(name, set, "hash splats and computed entries are not supported")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
LiteralKey.read(association[1]) ||
|
|
43
|
+
raise(unsupported(name, set, "every property key must be a literal label, Symbol, or String"))
|
|
44
|
+
end.map { |key| Property.normalize_key(key) }.uniq.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def returned_hash(lambda_node, name, set)
|
|
48
|
+
statements = lambda_node[2]
|
|
49
|
+
statements = statements[1] if statements&.first == :bodystmt
|
|
50
|
+
meaningful = Array(statements).reject { |node| node.is_a?(Array) && node.first == :void_stmt }
|
|
51
|
+
body = meaningful.one? ? meaningful.first : nil
|
|
52
|
+
body = body[1] if body&.first == :paren
|
|
53
|
+
|
|
54
|
+
unless body&.first == :hash
|
|
55
|
+
raise unsupported(name, set, "lambda must return one literal Hash")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
body
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def hash_associations(hash_node)
|
|
62
|
+
# Ripper represents a hash as [:hash, [:assoclist_from_args, entries]].
|
|
63
|
+
hash_node.dig(1, 1) || []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unsupported(name, set, detail)
|
|
67
|
+
Unsupported.new(
|
|
68
|
+
"Dynamic trait #{name}.#{set} cannot be compiled: #{detail}. " \
|
|
69
|
+
"Return one literal Hash with static property keys, or declare " \
|
|
70
|
+
"`trait :#{set}, dynamic: %i[...], using: -> { ... }`."
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
class DynamicStyle
|
|
6
|
+
attr_reader :name, :set
|
|
7
|
+
|
|
8
|
+
def initialize(name:, set:, fn:, properties: nil)
|
|
9
|
+
@name = Immutable.copy(name)
|
|
10
|
+
@set = Immutable.copy(set)
|
|
11
|
+
@fn = fn
|
|
12
|
+
@required_arguments = fn.parameters.any? { |kind, _| kind == :req || kind == :keyreq }
|
|
13
|
+
@vars = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@declared_properties = normalize_properties(properties)
|
|
16
|
+
@compiled_properties = @declared_properties
|
|
17
|
+
compile! if Atomix.config.inline?
|
|
18
|
+
Atomix.registry.after_commit { Atomix.dynamic_styles << self }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def source_path
|
|
22
|
+
@fn.source_location&.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def arity
|
|
26
|
+
@fn.arity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(*args, **kwargs)
|
|
30
|
+
return Style::EMPTY if args.empty? && kwargs.empty? && required_arguments?
|
|
31
|
+
result = @fn.call(*args, **kwargs)
|
|
32
|
+
unless result.is_a?(Hash)
|
|
33
|
+
raise ArgumentError, "Dynamic trait #{set.inspect} must return a Hash, got #{result.class}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
classes = {}
|
|
37
|
+
inline = {}
|
|
38
|
+
result.each do |key, value|
|
|
39
|
+
property = Property.normalize_key(key)
|
|
40
|
+
if @compiled_properties && !@compiled_properties.include?(property)
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"Dynamic trait #{name}.#{set} returned undeclared property #{property.inspect}. " \
|
|
43
|
+
"Compiled properties: #{@compiled_properties.inspect}"
|
|
44
|
+
end
|
|
45
|
+
if value.nil? || value == false
|
|
46
|
+
classes[property] = nil
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if value.is_a?(Hash)
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"Dynamic trait #{name}.#{set} cannot use conditions for #{property.inspect}. " \
|
|
53
|
+
"Declare conditions in a static trait referencing a CSS variable instead."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if value.is_a?(Array)
|
|
57
|
+
raise ArgumentError,
|
|
58
|
+
"Dynamic trait #{name}.#{set} cannot use an Array for #{property.inspect}. " \
|
|
59
|
+
"Declare fallback arrays in a static trait; dynamic values must be single CSS values."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
var, class_name = identifiers(property)
|
|
63
|
+
classes[property] = [class_name]
|
|
64
|
+
inline[var] = Property.normalize_value(property, value)
|
|
65
|
+
end
|
|
66
|
+
Style.new(classes, inline)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compile!
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@compiled_properties ||= DynamicProperties.extract(@fn, name: name, set: set)
|
|
72
|
+
end
|
|
73
|
+
Atomix.registry.with_source(@fn.source_location&.first) do
|
|
74
|
+
@compiled_properties.each { |property| ensure_registered(property) }
|
|
75
|
+
end
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def required_arguments?
|
|
82
|
+
@required_arguments
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_properties(properties)
|
|
86
|
+
return unless properties
|
|
87
|
+
|
|
88
|
+
Immutable.copy(properties.map { |property| Property.normalize_key(property) }.uniq)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def identifiers(property)
|
|
92
|
+
@mutex.synchronize { @vars[property] ||= build_identifiers(property) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ensure_registered(property)
|
|
96
|
+
var, class_name = identifiers(property)
|
|
97
|
+
Atomix.registry.add(
|
|
98
|
+
Rule.new(selector: ".#{class_name}", property: property, value: "var(#{var})")
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_identifiers(property)
|
|
103
|
+
var = Hashing.var_name(name, set, property)
|
|
104
|
+
value = "var(#{var})"
|
|
105
|
+
[var, Hashing.class_name(property, value)].freeze
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
class EnumeratedStyle
|
|
6
|
+
attr_reader :name, :members, :default
|
|
7
|
+
|
|
8
|
+
def initialize(name:, members:, default: nil)
|
|
9
|
+
@name = Immutable.copy(name)
|
|
10
|
+
@members = Immutable.copy(members)
|
|
11
|
+
@default = Immutable.copy(default)
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(value = nil)
|
|
16
|
+
value = default if value.nil? || value == false
|
|
17
|
+
return Style::EMPTY if value.nil?
|
|
18
|
+
|
|
19
|
+
unless value.respond_to?(:to_sym)
|
|
20
|
+
raise KeyError, "Unknown member #{value.inspect} for trait #{name.inspect}. Available: #{members.keys.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
key = value.to_sym
|
|
24
|
+
return members[key] if members.key?(key)
|
|
25
|
+
|
|
26
|
+
raise KeyError, "Unknown member #{value.inspect} for trait #{name.inspect}. Available: #{members.keys.inspect}"
|
|
27
|
+
end
|
|
28
|
+
alias [] call
|
|
29
|
+
|
|
30
|
+
def member_names
|
|
31
|
+
members.keys
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def empty?
|
|
35
|
+
members.empty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
|
|
5
|
+
module ViewComponent
|
|
6
|
+
module Atomix
|
|
7
|
+
module Hashing
|
|
8
|
+
# Twelve base-36 characters retain roughly 62 bits of SHA1. This keeps
|
|
9
|
+
# selectors compact while making collisions impractical for application
|
|
10
|
+
# sized stylesheets.
|
|
11
|
+
LENGTH = 12
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def digest(*parts)
|
|
16
|
+
input = parts.map { |part| serialize(part) }.join
|
|
17
|
+
Digest::SHA1.hexdigest(input).to_i(16).to_s(36)[0, LENGTH]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serialize(value)
|
|
21
|
+
case value
|
|
22
|
+
when Array
|
|
23
|
+
encoded = value.map { |item| serialize(item) }.join
|
|
24
|
+
"a#{value.length}:#{encoded.bytesize}:#{encoded}"
|
|
25
|
+
when Symbol
|
|
26
|
+
serialize_scalar("y", value.to_s)
|
|
27
|
+
when nil
|
|
28
|
+
"n"
|
|
29
|
+
else
|
|
30
|
+
serialize_scalar("s", value.to_s)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def serialize_scalar(type, value)
|
|
35
|
+
"#{type}#{value.bytesize}:#{value}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def class_name(property, value, conditions = [])
|
|
39
|
+
"#{Atomix.config.class_prefix}#{digest(property, value, *conditions)}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def var_name(*parts)
|
|
43
|
+
"--#{Atomix.config.class_prefix}#{digest(*parts)}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
module Helper
|
|
6
|
+
include AttributeHelpers
|
|
7
|
+
|
|
8
|
+
def view_component_atomix_tag(**options)
|
|
9
|
+
config = ViewComponent::Atomix.config
|
|
10
|
+
options[:nonce] = content_security_policy_nonce if options[:nonce] == true
|
|
11
|
+
|
|
12
|
+
if config.inline?
|
|
13
|
+
css = ViewComponent::Atomix.registry.to_css
|
|
14
|
+
css = css.gsub("</", "<\\/") if css.include?("</")
|
|
15
|
+
content_tag(:style, css.html_safe, { "data-view-component-atomix": "true" }.merge(options))
|
|
16
|
+
else
|
|
17
|
+
stylesheet_link_tag(stylesheet_logical_path(config.output_path),
|
|
18
|
+
{ "data-turbo-track": "reload" }.merge(options))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def stylesheet_logical_path(output_path)
|
|
25
|
+
path = output_path.to_s.delete_suffix(".css")
|
|
26
|
+
path = path.delete_prefix("#{Rails.root}/") if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
27
|
+
path.sub(%r{\Aapp/assets/(?:builds|stylesheets)/}, "")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponent
|
|
4
|
+
module Atomix
|
|
5
|
+
# Value objects own their containers and strings. Domain objects stored in
|
|
6
|
+
# sheets (including callable dynamic styles) retain their own lifecycle.
|
|
7
|
+
module Immutable
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def copy(value)
|
|
11
|
+
case value
|
|
12
|
+
when Hash then value.to_h { |key, item| [copy(key), copy(item)] }.freeze
|
|
13
|
+
when Array then value.map { |item| copy(item) }.freeze
|
|
14
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
15
|
+
else value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|