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,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