classy-yaml 1.7.2 → 1.7.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97e19478fc64b51321f597e6449e4a6b72708391561401a1fcd8c09996f1ace2
4
- data.tar.gz: 428fb76c90fea87eb601ab917fa86715e2ed0634669ab0ddf8e061da9087ac85
3
+ metadata.gz: 177a550a2cc9c2678b05d5ccfc29aeb690949e1e99f604bc78f4a728fac96fc9
4
+ data.tar.gz: 1afe8baf1045c0bc23e60916a0f4dba19cd8d39f0562ed60bd58e3a914613238
5
5
  SHA512:
6
- metadata.gz: 8aef2b74c04eacb3573d5958108559f729965abc7766be1441dd903d62b2375f98e6b917e0d055fc8b0e2246bff4c959242ee8eacfa29a5c8827fdc725a85393
7
- data.tar.gz: e2a5ce4eccecd4ada862ba28e98bd731b9e37ad54d6fb8060b5a06471cd906d805c78ec473f426a5a98de49078c3df65cffbc780171a557d66151793b3d91743
6
+ metadata.gz: ec10f751c02f2e49cd8aa176a0b0361a82c0a7feb0cc7a68518cadf94c6008b39efd5b1c3b210c699c01cd4f6517d3af3e97231f3d115d98313fe7ba1ce20eea
7
+ data.tar.gz: 6b9e997a94be56439b79765aef5117b54cac23e40681634bc56666413ca68a29debdce78cab0ccf83a0b77f4d7c8c3260dbdb99419fb0b0fe5dbdd02d87ae2e2
data/README.md CHANGED
@@ -144,11 +144,14 @@ Classy YAML detects if [tailwind_merge](https://github.com/gjtorikian/tailwind_m
144
144
 
145
145
  ## Caching and development reloads
146
146
 
147
- Classy YAML reuses parsed YAML when file metadata has not changed. In development and tests, each call checks the default, engine, extra, and component files. Edits, additions, and deletions take effect on the next call without a restart.
147
+ Classy YAML needs no configuration for its caches. The railtie chooses their behavior from `Rails.application.config.cache_classes`.
148
148
 
149
- In production, the default and engine YAML remain cached until configuration resets or the process restarts. Extra and component files still receive metadata checks. `Classy::Yaml.setup` clears the YAML caches.
149
+ - **Results.** `yass` remembers the result for each set of arguments (including `add:`, `skip_base:` and `classy_files:`) in a bounded least-recently-used cache of `Classy::Yaml.cache_size` entries (5,000 by default). A repeated call does no YAML lookup and no class merge. A call that logs an invalid key warning is not cached, so the warning appears on every call. Set `Classy::Yaml.cache_size = 0` to turn the result cache off.
150
+ - **YAML files.** Each file is parsed once and compiled to frozen lookup data. A missing or invalid file is also remembered.
151
+ - **Classes cached (test, production).** Classy YAML never checks the files again. Edits take effect after a restart or `Classy::Yaml.setup`. At boot, Classy YAML parses the default, engine, and extra files and builds the `tailwind_merge` merger, so forked workers (Puma `preload_app!`, parallel tests) share them.
152
+ - **Classes reloaded (development).** At the start of each request or job (the Rails executor), Classy YAML marks its files for a check. The next `yass` call checks each YAML file it has read once, including missing component files. An edit, addition, deletion, or replacement takes effect on the next request. Outside the executor, for example in a console, call `reload!`.
150
153
 
151
- When `tailwind_merge` is available, Classy YAML reuses one merger per process in all environments. A lock protects the merger cache during concurrent calls.
154
+ `Classy::Yaml.setup` and the file setters clear all caches. When `tailwind_merge` is available, Classy YAML reuses one merger per process in all environments. A lock protects the merger cache during concurrent calls.
152
155
 
153
156
  ## Configuration Options
154
157
 
@@ -158,6 +161,7 @@ When `tailwind_merge` is available, Classy YAML reuses one merger per process in
158
161
  | `extra_files` | `[]` | Array of additional YAML files (above default and engine files; below component files) |
159
162
  | `engine_files` | `[]` | Array of engine YAML files (lowest priority) |
160
163
  | `override_tag_helpers` | `false` | Automatically process class symbols/hashes in Rails tag helpers |
164
+ | `cache_size` | `5000` | Maximum number of cached `yass` results; `0` turns the result cache off |
161
165
 
162
166
  ## Contributing
163
167
 
@@ -2,19 +2,12 @@ module Classy
2
2
  module Yaml
3
3
  module ComponentHelpers
4
4
  def yass(*args)
5
- source_file = Object.const_source_location(self.class.name).first
6
- calling_path = File.dirname(source_file)
7
- calling_file = File.basename(source_file).split(".").first
8
- component_name = self.class.name.underscore.split("/").last.split(".").first
9
-
10
- classy_files = [ "#{calling_path}/#{component_name}.yml",
11
- "#{calling_path}/#{calling_file}/#{calling_file}.yml",
12
- "#{calling_path}/#{calling_file}/#{component_name}.yml" ]
5
+ classy_files = Classy::Yaml.component_classy_files(self.class)
13
6
 
14
7
  if args.first.is_a?(Hash)
15
- args[0] = args.first.merge(classy_files: classy_files.uniq)
8
+ args[0] = args.first.merge(classy_files: classy_files)
16
9
  else
17
- args << { classy_files: classy_files.uniq }
10
+ args << { classy_files: classy_files }
18
11
  end
19
12
 
20
13
  helpers.yass(*args)
@@ -3,9 +3,22 @@ require "rails"
3
3
  module Classy
4
4
  module Yaml
5
5
  class Engine < Rails::Railtie
6
+ # Each request or job starts with one check of the YAML files while Rails
7
+ # reloads code, instead of a check on every lookup.
8
+ initializer "classy_yaml.file_check" do |app|
9
+ app.executor.to_run { Classy::Yaml.files_may_have_changed }
10
+ end
11
+
6
12
  config.to_prepare do
13
+ Classy::Yaml.clear_component_files
14
+ Classy::Yaml.files_may_have_changed
7
15
  ApplicationController.helper(Classy::Yaml::Helpers)
8
16
  end
17
+
18
+ # With classes cached, parse the YAML before workers fork.
19
+ config.after_initialize do |app|
20
+ Classy::Yaml.warm if app.config.cache_classes
21
+ end
9
22
  end
10
23
  end
11
24
  end
@@ -11,154 +11,17 @@ module Classy
11
11
  # @param args [Array] Array of keys to look up in the YAML files
12
12
  # @return [String] Space-separated list of CSS classes
13
13
  def yass(*args)
14
- # Start with engine YAMLs (lowest priority)
15
- classy_yamls = Classy::Yaml.cached_engine_yamls.dup
16
-
17
- # Add default YAML (next priority)
18
- default_yaml = Classy::Yaml.cached_default_yaml
19
- classy_yamls << default_yaml if default_yaml
20
-
21
- # Add extra files (highest priority)
22
- Classy::Yaml.extra_files.each do |file_path|
23
- load_yaml_file(file_path, classy_yamls, "extra")
24
- end
25
-
26
- # Add classy_files (highest priority)
27
- classy_files_hash = args.find { |arg| arg.is_a?(Hash) && arg.key?(:classy_files) } || { classy_files: [] }
28
- classy_files_hash[:classy_files].each do |file_path|
29
- load_yaml_file(file_path, classy_yamls, "classy")
30
- end
31
-
32
- skip_base_hash = args.find { |arg| arg.is_a?(Hash) && arg.key?(:skip_base) } || {}
33
- keys, add_classes = flatten_args(values: args)
34
- classes = fetch_classes(keys.uniq, classy_yamls: classy_yamls, skip_base: skip_base_hash[:skip_base])
35
- classes += add_classes
36
-
37
- # Use tailwind_merge if available, otherwise fall back to simple join
38
- merge_classes(classes.flatten.uniq)
14
+ Classy::Yaml.classes_for(args, tailwind_merge_available?)
39
15
  end
40
16
 
41
17
  private
42
18
 
43
- # Merges CSS classes using tailwind_merge if available, otherwise uses simple join
44
- # @param classes [Array] Array of CSS class strings
45
- # @return [String] Merged CSS classes
46
- def merge_classes(classes)
47
- return "" if classes.blank?
48
-
49
- if tailwind_merge_available?
50
- # Use tailwind_merge for intelligent class merging
51
- Classy::Yaml.merge_classes(classes)
52
- else
53
- # Fall back to simple space-joined classes
54
- classes.join(" ")
55
- end
56
- end
57
-
58
19
  # Checks if tailwind_merge gem is available
59
20
  # @return [Boolean] True if tailwind_merge is available
60
21
  def tailwind_merge_available?
61
22
  return @tailwind_merge_available if defined?(@tailwind_merge_available)
62
- @tailwind_merge_available = begin
63
- require "tailwind_merge"
64
- true
65
- rescue LoadError
66
- false
67
- end
68
- end
69
-
70
- # Loads a YAML file and adds its contents to the classy_yamls array
71
- # @param file_path [String, Pathname] Path to the YAML file
72
- # @param classy_yamls [Array] Array to add the parsed YAML to
73
- # @param file_type [String] Type of file being loaded (for error messages)
74
- def load_yaml_file(file_path, classy_yamls, file_type)
75
- parsed_yaml = Classy::Yaml.cached_yaml_file(file_path, file_type)
76
- classy_yamls << parsed_yaml if parsed_yaml
77
- end
78
-
79
- # Flattens the arguments into keys and classes
80
- # @param root [Array] Current root path in the argument tree
81
- # @param values [Array] Values to process
82
- # @param keys [Array] Array to store found keys
83
- # @param added_classes [Array] Array to store found classes
84
- # @return [Array] Tuple of [keys, added_classes]
85
- def flatten_args(root: [], values: [], keys: [], added_classes: [])
86
- parent_keys = []
87
- values.each do |value|
88
- case value
89
- when Hash
90
- added_classes << value[:add] if value.key?(:add)
91
- value.each do |key, child|
92
- next if key == :add
93
- next if root.empty? && [ :skip_base, :classy_files ].include?(key)
94
-
95
- path = root + [ key.to_s ]
96
- parent_keys << path
97
- flatten_args(root: path, values: [ child ], keys: keys, added_classes: added_classes)
98
- end
99
- when Array
100
- flatten_args(root: root, values: value, keys: keys, added_classes: added_classes)
101
- else
102
- keys << (root + [ value.to_s ])
103
- end
104
- end
105
- keys.concat(parent_keys)
106
- [ keys, added_classes ]
107
- end
108
-
109
- # Fetches classes from the YAML files based on the provided keys
110
- # @param keys [Array] Array of keys to look up
111
- # @param classy_yamls [Array] Array of YAML files to search in
112
- # @param skip_base [Boolean] Whether to skip base classes
113
- # @return [Array] Array of found classes
114
- def fetch_classes(keys, classy_yamls: [], skip_base: false)
115
- classes = []
116
-
117
- keys.each do |key|
118
- base_classes = nil
119
- fetched_classes = nil
120
-
121
- classy_yamls.reverse_each do |classy_yaml|
122
- begin
123
- value = classy_yaml.dig(*key)
124
- unless skip_base == true || base_classes
125
- base_value = if value.is_a?(Hash)
126
- value["base"]
127
- elsif key.length > 1
128
- classy_yaml.dig(*(key[0...-1] + [ "base" ]))
129
- end
130
- base_classes = normalize_original(base_value).presence
131
- end
132
-
133
- unless fetched_classes || value.is_a?(Hash)
134
- fetched_classes = normalize_original(value).presence
135
- end
136
- rescue StandardError
137
- Rails.logger.warn(Classy::Yaml::InvalidKeyError.new(data: key))
138
- end
139
-
140
- break if fetched_classes && (skip_base == true || base_classes)
141
- end
142
-
143
- classes << base_classes unless base_classes.blank?
144
- classes << fetched_classes unless fetched_classes.blank?
145
- end
146
-
147
- classes.flatten.uniq
148
- end
149
23
 
150
- # Normalizes a value into an array of classes
151
- # @param value [String, Array, nil] Value to normalize
152
- # @return [Array, nil] Array of classes or nil if value is invalid
153
- def normalize_original(value)
154
- case value
155
- when String
156
- value.split(" ").reject(&:blank?)
157
- when Array
158
- value.flatten.map(&:to_s).reject(&:blank?)
159
- else
160
- nil
161
- end
24
+ Classy::Yaml.tailwind_merge_available?
162
25
  end
163
26
  end
164
27
  end
@@ -0,0 +1,181 @@
1
+ module Classy
2
+ module Yaml
3
+ # Turns yass arguments into classes. The YAML layers are compiled once at load
4
+ # (see Classy::Yaml.compile), so a lookup only walks frozen hashes and arrays.
5
+ module Lookup
6
+ EMPTY = [].freeze
7
+ # Returned by walk when a key path goes through a value that is not a hash.
8
+ INVALID = Object.new.freeze
9
+ # A compiled YAML array that refers to itself. 1.7.2 raised on it while
10
+ # normalizing classes, so using it logs a warning and skips the layer.
11
+ RECURSIVE = Object.new.freeze
12
+ # Cache key markers. Hash order changes the output, so a key lists hash
13
+ # entries in order instead of using Hash#eql?, which ignores order.
14
+ HASH_START = Object.new.freeze
15
+ ARRAY_START = Object.new.freeze
16
+ CLOSE = Object.new.freeze
17
+
18
+ module_function
19
+
20
+ # Returns an order-sensitive cache key for the arguments, or nil when an
21
+ # argument is not a plain value. Only the exact core classes qualify: a
22
+ # subclass can override #to_s, #each or #key? and change the result.
23
+ def cache_key(args, tailwind)
24
+ key = [ tailwind ]
25
+ append_key(args, key) ? key : nil
26
+ end
27
+
28
+ def append_key(value, key)
29
+ case value
30
+ when Symbol, Integer, Float, nil, true, false
31
+ key << value
32
+ when String, Pathname
33
+ return false unless value.instance_of?(String) || value.instance_of?(Pathname)
34
+
35
+ key << value
36
+ when Array
37
+ return false unless value.instance_of?(Array)
38
+
39
+ key << ARRAY_START
40
+ value.each { |child| return false unless append_key(child, key) }
41
+ key << CLOSE
42
+ when Hash
43
+ return false unless value.instance_of?(Hash)
44
+
45
+ key << HASH_START
46
+ value.each { |name, child| return false unless append_key(name, key) && append_key(child, key) }
47
+ key << CLOSE
48
+ else
49
+ return false
50
+ end
51
+ true
52
+ end
53
+
54
+ # Freezes a cache key built by cache_key. Unfrozen strings are replaced by
55
+ # frozen copies, so a caller that later mutates its strings cannot change it.
56
+ def freeze_key(key)
57
+ key.each_with_index do |part, index|
58
+ key[index] = part.dup.freeze if part.is_a?(String) && !part.frozen?
59
+ end
60
+ key.freeze
61
+ end
62
+
63
+ # Collects the lookup key paths and add: values in the order 1.7.2 used:
64
+ # leaf paths first, then the parent paths of each list of values.
65
+ def collect_list(values, root, keys, adds)
66
+ parents = nil
67
+ values.each { |value| parents = collect(value, root, keys, adds, parents) }
68
+ keys.concat(parents) if parents
69
+ end
70
+
71
+ def collect(value, root, keys, adds, parents)
72
+ case value
73
+ when Hash
74
+ adds << value[:add] if value.key?(:add)
75
+ value.each do |name, child|
76
+ next if name == :add
77
+ next if root.empty? && (name == :skip_base || name == :classy_files)
78
+
79
+ path = [ *root, segment(name) ]
80
+ (parents ||= []) << path
81
+ nested = collect(child, path, keys, adds, nil)
82
+ keys.concat(nested) if nested
83
+ end
84
+ when Array
85
+ collect_list(value, root, keys, adds)
86
+ else
87
+ keys << [ *root, segment(value) ]
88
+ end
89
+ parents
90
+ end
91
+
92
+ def segment(value)
93
+ value.is_a?(Symbol) ? value.name : value.to_s
94
+ end
95
+
96
+ # Resolves each key path against the layers (lowest priority first) and
97
+ # appends base and specific classes. Returns false when a path went through
98
+ # a value that is not a hash; that call logged a warning.
99
+ def resolve(keys, layers, skip_base, classes)
100
+ clean = true
101
+ skip_base = skip_base == true
102
+ keys.each do |path|
103
+ clean = false unless resolve_path(path, layers, skip_base, classes)
104
+ end
105
+ clean
106
+ end
107
+
108
+ def resolve_path(path, layers, skip_base, classes)
109
+ clean = true
110
+ base_classes = nil
111
+ fetched_classes = nil
112
+ last = path.size - 1
113
+ index = layers.size - 1
114
+ while index >= 0
115
+ layer = layers[index]
116
+ index -= 1
117
+ parent = walk(layer, path, last)
118
+ value = case parent
119
+ when Hash then parent[path[last]]
120
+ when nil then nil
121
+ else INVALID
122
+ end
123
+
124
+ if value.equal?(INVALID)
125
+ clean = false
126
+ Rails.logger.warn(Classy::Yaml::InvalidKeyError.new(data: path))
127
+ else
128
+ # 1.7.2 computed the base, then the classes, and a recursive array
129
+ # raised in either step, which skipped the rest of this layer.
130
+ unless skip_base || base_classes
131
+ base_value = if value.is_a?(Hash)
132
+ value["base"]
133
+ elsif last.positive? && parent.is_a?(Hash)
134
+ parent["base"]
135
+ end
136
+ base_classes = classes_of(base_value)
137
+ end
138
+ unless fetched_classes || value.is_a?(Hash) || base_classes.equal?(RECURSIVE)
139
+ fetched_classes = classes_of(value)
140
+ end
141
+ if base_classes.equal?(RECURSIVE) || fetched_classes.equal?(RECURSIVE)
142
+ base_classes = nil if base_classes.equal?(RECURSIVE)
143
+ fetched_classes = nil if fetched_classes.equal?(RECURSIVE)
144
+ clean = false
145
+ Rails.logger.warn(Classy::Yaml::InvalidKeyError.new(data: path))
146
+ end
147
+ end
148
+
149
+ break if fetched_classes && (skip_base || base_classes)
150
+ end
151
+
152
+ classes.concat(base_classes) if base_classes
153
+ classes.concat(fetched_classes) if fetched_classes
154
+ clean
155
+ end
156
+
157
+ # Follows path[0...depth] from node. Returns nil when a key is missing, and
158
+ # INVALID when the path continues through a value that is not a hash.
159
+ def walk(node, path, depth)
160
+ index = 0
161
+ while index < depth
162
+ case node
163
+ when Hash then node = node[path[index]]
164
+ when nil then return nil
165
+ else return INVALID
166
+ end
167
+ index += 1
168
+ end
169
+ node
170
+ end
171
+
172
+ # A compiled leaf is a frozen array of classes; anything else has none.
173
+ # A recursive array is returned as RECURSIVE for the caller to report.
174
+ def classes_of(value)
175
+ return value if value.equal?(RECURSIVE)
176
+
177
+ value if value.is_a?(Array) && !value.empty?
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,68 @@
1
+ module Classy
2
+ module Yaml
3
+ # A bounded, thread-safe least-recently-used cache of yass results.
4
+ #
5
+ # Entries are indexed by the key's hash, computed once per call, and keep the
6
+ # key itself so a lookup only hits when the keys are eql?. A store carries the
7
+ # generation its result was computed under; a store from an older generation
8
+ # is dropped, so a lookup that raced a reload or a `setup` never caches an
9
+ # answer built from the old YAML.
10
+ class ResultCache
11
+ Entry = Struct.new(:key, :value)
12
+
13
+ attr_reader :max_size
14
+
15
+ def initialize(max_size)
16
+ @max_size = max_size
17
+ @entries = {}
18
+ @generation = 0
19
+ @lock = Mutex.new
20
+ end
21
+
22
+ def generation
23
+ @generation
24
+ end
25
+
26
+ def max_size=(value)
27
+ @lock.synchronize do
28
+ @max_size = value
29
+ @entries.shift while @entries.size > @max_size
30
+ end
31
+ end
32
+
33
+ def size
34
+ @entries.size
35
+ end
36
+
37
+ def get(key, digest = key.hash)
38
+ @lock.synchronize do
39
+ entry = @entries.delete(digest)
40
+ return unless entry
41
+
42
+ # Move the entry to the newest end; a colliding key is a miss and its
43
+ # entry is replaced by the next store.
44
+ @entries[digest] = entry
45
+ entry.value if entry.key.eql?(key)
46
+ end
47
+ end
48
+
49
+ def store(key, value, generation, digest = key.hash)
50
+ @lock.synchronize do
51
+ return unless generation == @generation && @max_size.positive?
52
+
53
+ @entries.delete(digest)
54
+ @entries[digest] = Entry.new(key, value).freeze
55
+ @entries.shift if @entries.size > @max_size
56
+ end
57
+ end
58
+
59
+ # Drops every result and invalidates stores already in progress.
60
+ def invalidate
61
+ @lock.synchronize do
62
+ @generation += 1
63
+ @entries.clear
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,5 +1,5 @@
1
1
  module Classy
2
2
  module Yaml
3
- VERSION = "1.7.2"
3
+ VERSION = "1.7.3"
4
4
  end
5
5
  end
data/lib/classy/yaml.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "concurrent/map"
1
2
  require "classy/yaml/version"
2
3
  require "classy/yaml/engine"
3
4
 
@@ -20,29 +21,42 @@ module Classy
20
21
  autoload :Helpers, "classy/yaml/helpers"
21
22
  autoload :ComponentHelpers, "classy/yaml/component_helpers"
22
23
  autoload :InvalidKeyError, "classy/yaml/invalid_key_error"
24
+ autoload :Lookup, "classy/yaml/lookup"
25
+ autoload :ResultCache, "classy/yaml/result_cache"
23
26
 
24
- # -- Class Instance Variables for Caching --
25
- @cached_engine_yamls = nil
26
- @cached_default_yaml = nil
27
- @load_lock = Mutex.new # Prevent race conditions during lazy loading
27
+ # The number of yass results kept for reuse. Set to 0 to turn the result cache off.
28
+ DEFAULT_CACHE_SIZE = 5_000
29
+
30
+ FileEntry = Struct.new(:signature, :layer)
31
+
32
+ # -- Caches --
33
+ # Parsed and compiled YAML files, keyed by full path. While Rails reloads code,
34
+ # each entry keeps the file signature that check_for_changes compares.
28
35
  @file_cache = {}
36
+ # Configured and component paths resolved against Rails.root.
37
+ @full_paths = {}
29
38
  @file_lock = Mutex.new
39
+ # The engine, default and extra layers, compiled for the current generation.
40
+ @static_layers = nil
41
+ @results = ResultCache.new(DEFAULT_CACHE_SIZE)
42
+ @component_files = Concurrent::Map.new
43
+ @check_pending = false
30
44
  @merger_lock = Mutex.new
31
45
 
32
46
  # -- Configuration Setters with Path Resolution --
33
47
  def self.engine_files=(value)
34
48
  @@engine_files = Array.wrap(value).reject(&:blank?).map { |file| Rails.root.join(file) }
35
- @cached_engine_yamls = nil # Clear cache on reassignment
49
+ reset_caches
36
50
  end
37
51
 
38
52
  def self.extra_files=(value)
39
53
  @@extra_files = Array.wrap(value).reject(&:blank?).map { |file| Rails.root.join(file) }
40
- # Parsed files use the shared file cache.
54
+ reset_caches
41
55
  end
42
56
 
43
57
  def self.default_file=(value)
44
58
  @@default_file = value
45
- @cached_default_yaml = nil # Clear cache on reassignment
59
+ reset_caches
46
60
  end
47
61
 
48
62
  def self.override_tag_helpers=(value)
@@ -50,109 +64,167 @@ module Classy
50
64
  apply_tag_helper_override if value
51
65
  end
52
66
 
53
- # -- Cached Data Accessors (Lazy Loading) --
54
- def self.cached_engine_yamls
55
- # Check file metadata only while Rails reloads code
56
- return load_engine_yamls if reloading?
57
-
58
- # Classes are cached: parse once
59
- return @cached_engine_yamls if @cached_engine_yamls
60
-
61
- @load_lock.synchronize do
62
- # Double-check idiom to ensure loading happens only once
63
- return @cached_engine_yamls if @cached_engine_yamls
64
- @cached_engine_yamls = load_engine_yamls
65
- end
67
+ def self.cache_size
68
+ @results.max_size
66
69
  end
67
70
 
68
- def self.cached_default_yaml
69
- # Check file metadata only while Rails reloads code
70
- return load_default_yaml if reloading?
71
+ # Accepts an Integer, or a String holding one (for example from ENV), of 0 or more.
72
+ # Integer() would truncate a Float such as -0.5 to 0, so floats are rejected.
73
+ def self.cache_size=(value)
74
+ size = case value
75
+ when Integer then value
76
+ when String then Integer(value, 10, exception: false)
77
+ end
78
+ unless size && !size.negative?
79
+ raise ArgumentError, "Classy::Yaml.cache_size must be an integer of 0 or more, got #{value.inspect}"
80
+ end
71
81
 
72
- # Classes are cached: parse once
73
- return @cached_default_yaml if @cached_default_yaml
82
+ @results.max_size = size
83
+ end
74
84
 
75
- @load_lock.synchronize do
76
- return @cached_default_yaml if @cached_default_yaml
77
- @cached_default_yaml = load_default_yaml
78
- end
85
+ # Increases on every configuration change and every YAML file change.
86
+ def self.generation
87
+ @results.generation
79
88
  end
80
89
 
81
90
  # -- Setup Method --
82
91
  def self.setup
83
92
  yield self
84
93
  # Clear all caches when configuration changes
85
- @cached_engine_yamls = nil
86
- @cached_default_yaml = nil
87
- @file_lock.synchronize { @file_cache.clear }
94
+ reset_caches(files: true)
88
95
  # Apply tag helper override if enabled
89
96
  apply_tag_helper_override if @@override_tag_helpers
90
97
  end
91
98
 
92
- private
99
+ # Returns the classes for yass arguments. Results are reused until a
100
+ # configuration change or (while Rails reloads code) a YAML file change.
101
+ def self.classes_for(args, tailwind)
102
+ check_for_changes if @check_pending
103
+ key = Lookup.cache_key(args, tailwind) if @results.max_size.positive?
104
+ if key
105
+ digest = key.hash
106
+ cached = @results.get(key, digest)
107
+ return cached.is_a?(String) ? cached : cached.first.dup if cached
108
+ end
93
109
 
94
- def self.apply_tag_helper_override
95
- require "classy/yaml/tag_helper"
110
+ generation = @results.generation
111
+ result, cacheable = build_classes(args, tailwind)
112
+ if key && cacheable
113
+ # A frozen result is shared as 1.7.2 shared tailwind_merge's cached string;
114
+ # any other result is returned as a new unfrozen copy each time.
115
+ value = result.frozen? ? result : [ result.dup.freeze ].freeze
116
+ @results.store(Lookup.freeze_key(key), value, generation, digest)
117
+ end
118
+ result
119
+ end
96
120
 
97
- ActiveSupport.on_load(:action_view) do
98
- ActionView::Helpers::TagHelper::TagBuilder.prepend(Classy::Yaml::TagHelper)
121
+ # Parses the YAML files and builds the merger before Puma or the test runner
122
+ # forks, so workers share them copy-on-write and the first request pays nothing.
123
+ def self.warm
124
+ static_layers
125
+ @merger_lock.synchronize { @merger ||= TailwindMerge::Merger.new } if tailwind_merge_available?
126
+ end
127
+
128
+ # Called at the start of each request or job (the Rails executor) and after
129
+ # each code reload (to_prepare, which also covers `reload!` in a console).
130
+ # While Rails reloads code, the next lookup compares each YAML file it has
131
+ # read. With classes cached it does nothing, so no file is ever checked.
132
+ def self.files_may_have_changed
133
+ @check_pending = true if reloading?
134
+ end
135
+
136
+ # Stats every YAML file read so far (also missing ones) once. A change drops
137
+ # that file and every cached result (a new generation).
138
+ #
139
+ # Not ActiveSupport::FileUpdateChecker, for two reasons:
140
+ # - It detects an edit only when the newest mtime of all watched files grows
141
+ # (or the number of existing files changes). An edit that keeps the mtime,
142
+ # or a replacement by an older file, is missed.
143
+ # - Its files and directories are fixed when it is built, but component YAML
144
+ # paths are discovered at the first render, so each new path would need a
145
+ # new checker.
146
+ # The signature here is mtime, ctime, size, inode and device of each file
147
+ # looked up so far, and nil for a missing one.
148
+ def self.check_for_changes
149
+ @file_lock.synchronize do
150
+ return unless @check_pending
151
+
152
+ @check_pending = false
153
+ changed = @file_cache.select { |path, entry| entry.signature != file_signature(path) }
154
+ next if changed.empty?
155
+
156
+ changed.each_key { |path| @file_cache.delete(path) }
157
+ invalidate_results
99
158
  end
159
+ end
100
160
 
101
- apply_icon_helper_override
161
+ def self.reset_caches(files: false)
162
+ @file_lock.synchronize do
163
+ if files
164
+ @file_cache.clear
165
+ @full_paths.clear
166
+ end
167
+ @component_files = Concurrent::Map.new
168
+ invalidate_results
169
+ end
102
170
  end
103
171
 
104
- def self.apply_icon_helper_override
105
- return unless defined?(RailsIcons::Helpers::IconHelper)
172
+ # Must be called with @file_lock held.
173
+ def self.invalidate_results
174
+ @static_layers = nil
175
+ @results.invalidate
176
+ end
106
177
 
107
- require "classy/yaml/icon_helper"
178
+ def self.tailwind_merge_available?
179
+ return @tailwind_merge_available if defined?(@tailwind_merge_available)
108
180
 
109
- RailsIcons::Helpers::IconHelper.prepend(Classy::Yaml::IconHelper)
181
+ @tailwind_merge_available = begin
182
+ require "tailwind_merge"
183
+ true
184
+ rescue LoadError
185
+ false
186
+ end
110
187
  end
111
188
 
112
- def self.load_engine_yamls
113
- engine_files.map { |path| cached_yaml_file(path, "engine") }.compact
189
+ # The YAML candidates next to a component's source file, computed once per class.
190
+ def self.component_classy_files(component_class)
191
+ @component_files.compute_if_absent(component_class) do
192
+ source_file = Object.const_source_location(component_class.name).first
193
+ calling_path = File.dirname(source_file)
194
+ calling_file = File.basename(source_file).split(".").first
195
+ component_name = component_class.name.underscore.split("/").last.split(".").first
196
+
197
+ [ "#{calling_path}/#{component_name}.yml",
198
+ "#{calling_path}/#{calling_file}/#{calling_file}.yml",
199
+ "#{calling_path}/#{calling_file}/#{component_name}.yml" ].uniq.each(&:freeze).freeze
200
+ end
114
201
  end
115
202
 
116
- def self.load_default_yaml
117
- cached_yaml_file(default_file, "default")
203
+ # Reloaded component classes are new objects, so drop the old ones.
204
+ def self.clear_component_files
205
+ @component_files = Concurrent::Map.new
118
206
  end
119
207
 
120
- # Edits are picked up without a restart only while Rails reloads code.
121
- # With classes cached (test, production) a parsed file is never stat'ed again.
122
- def self.reloading?
123
- !Rails.application.config.cache_classes
208
+ # Engine files (lowest priority), the default file, then extra files.
209
+ def self.static_layers
210
+ @static_layers || begin
211
+ generation = @results.generation
212
+ layers = [
213
+ *engine_files.map { |path| cached_yaml_file(path, "engine") },
214
+ cached_yaml_file(default_file, "default"),
215
+ *extra_files.map { |path| cached_yaml_file(path, "extra") }
216
+ ].compact.freeze
217
+ @file_lock.synchronize { @static_layers = layers if generation == @results.generation }
218
+ layers
219
+ end
124
220
  end
125
221
 
126
- # Check metadata on each access while reloading so edits do not require a restart.
222
+ # Returns the compiled YAML of a file, or nil when it is missing or invalid.
127
223
  def self.cached_yaml_file(file_path, file_type)
128
224
  @file_lock.synchronize do
129
- begin
130
- path = Rails.root.join(file_path).to_s
131
- cached = @file_cache[path]
132
- return cached[:yaml] if cached && !reloading?
133
-
134
- stat = File.stat(path)
135
- signature = [ stat.mtime, stat.ctime, stat.size, stat.ino, stat.dev ]
136
- return cached[:yaml] if cached && cached[:signature] == signature
137
-
138
- content = File.read(path, encoding: "UTF-8")
139
- parsed = YAML.safe_load(content, permitted_classes: [ Symbol, String, Array, Hash ], aliases: true)
140
- yaml = parsed.is_a?(Hash) ? parsed : nil
141
- @file_cache[path] = { signature: signature, yaml: yaml }
142
- yaml
143
- rescue Errno::ENOENT, Errno::ENOTDIR
144
- # A missing file stays missing until the next restart when classes are cached.
145
- reloading? ? @file_cache.delete(path) : @file_cache[path] = { signature: nil, yaml: nil }
146
- nil
147
- rescue Psych::SyntaxError => e
148
- @file_cache.delete(path)
149
- Rails.logger.error "Classy::Yaml: Failed to parse #{file_type} YAML file #{file_path}: #{e.message}"
150
- nil
151
- rescue => e
152
- @file_cache.delete(path)
153
- Rails.logger.error "Classy::Yaml: Error loading #{file_type} YAML file #{file_path}: #{e.message}"
154
- nil
155
- end
225
+ path = @full_paths[file_path] ||= Rails.root.join(file_path).to_s.freeze
226
+ entry = @file_cache[path] ||= load_yaml_file(path, file_path, file_type)
227
+ entry.layer
156
228
  end
157
229
  end
158
230
 
@@ -163,5 +235,116 @@ module Classy
163
235
  @merger.merge(classes)
164
236
  end
165
237
  end
238
+
239
+ # Edits are picked up without a restart only while Rails reloads code.
240
+ # With classes cached (test, production) a parsed file is never stat'ed again.
241
+ def self.reloading?
242
+ !Rails.application.config.cache_classes
243
+ end
244
+
245
+ # Turns parsed YAML into frozen lookup data: a string becomes its classes, an
246
+ # array its flattened classes. Other values stay as they are and give no classes.
247
+ def self.compile(value, compiled_hashes = {}.compare_by_identity)
248
+ case value
249
+ when Hash
250
+ compiled_hashes[value] || begin
251
+ compiled = compiled_hashes[value] = {}
252
+ value.each { |key, child| compiled[key] = compile(child, compiled_hashes) }
253
+ compiled.freeze
254
+ end
255
+ when String
256
+ value.split(" ").reject(&:blank?).each(&:freeze).freeze
257
+ when Array
258
+ begin
259
+ value.flatten.map(&:to_s).reject(&:blank?).each(&:freeze).freeze
260
+ rescue ArgumentError
261
+ # flatten raises on a recursive array; lookups report it as 1.7.2 did.
262
+ Lookup::RECURSIVE
263
+ end
264
+ else
265
+ value
266
+ end
267
+ end
268
+
269
+ def self.apply_tag_helper_override
270
+ require "classy/yaml/tag_helper"
271
+
272
+ ActiveSupport.on_load(:action_view) do
273
+ ActionView::Helpers::TagHelper::TagBuilder.prepend(Classy::Yaml::TagHelper)
274
+ end
275
+
276
+ apply_icon_helper_override
277
+ end
278
+
279
+ def self.apply_icon_helper_override
280
+ return unless defined?(RailsIcons::Helpers::IconHelper)
281
+
282
+ require "classy/yaml/icon_helper"
283
+
284
+ RailsIcons::Helpers::IconHelper.prepend(Classy::Yaml::IconHelper)
285
+ end
286
+
287
+ class << self
288
+ private
289
+
290
+ def build_classes(args, tailwind)
291
+ layers = static_layers
292
+ classy_files = args.find { |arg| arg.is_a?(Hash) && arg.key?(:classy_files) }
293
+ if classy_files
294
+ layers = layers.dup
295
+ classy_files[:classy_files].each do |file_path|
296
+ layer = cached_yaml_file(file_path, "classy")
297
+ layers << layer if layer
298
+ end
299
+ end
300
+ skip_base = args.find { |arg| arg.is_a?(Hash) && arg.key?(:skip_base) }
301
+
302
+ keys = []
303
+ adds = []
304
+ Lookup.collect_list(args, Lookup::EMPTY, keys, adds)
305
+ keys.uniq!
306
+ classes = []
307
+ clean = Lookup.resolve(keys, layers, skip_base && skip_base[:skip_base], classes)
308
+ classes.uniq!
309
+ unless adds.empty?
310
+ classes.concat(adds)
311
+ classes.flatten!
312
+ classes.uniq!
313
+ end
314
+
315
+ result = if classes.empty?
316
+ +""
317
+ elsif tailwind
318
+ merge_classes(classes)
319
+ else
320
+ classes.join(" ")
321
+ end
322
+ [ result, clean ]
323
+ end
324
+
325
+ def load_yaml_file(path, file_path, file_type)
326
+ signature = file_signature(path)
327
+ return FileEntry.new(nil, nil).freeze unless signature
328
+
329
+ content = File.read(path, encoding: "UTF-8")
330
+ parsed = YAML.safe_load(content, permitted_classes: [ Symbol, String, Array, Hash ], aliases: true)
331
+ FileEntry.new(signature, parsed.is_a?(Hash) ? compile(parsed) : nil).freeze
332
+ rescue Errno::ENOENT, Errno::ENOTDIR
333
+ FileEntry.new(nil, nil).freeze
334
+ rescue Psych::SyntaxError => e
335
+ Rails.logger.error "Classy::Yaml: Failed to parse #{file_type} YAML file #{file_path}: #{e.message}"
336
+ FileEntry.new(signature, nil).freeze
337
+ rescue => e
338
+ Rails.logger.error "Classy::Yaml: Error loading #{file_type} YAML file #{file_path}: #{e.message}"
339
+ FileEntry.new(signature, nil).freeze
340
+ end
341
+
342
+ def file_signature(path)
343
+ stat = File.stat(path)
344
+ [ stat.mtime, stat.ctime, stat.size, stat.ino, stat.dev ]
345
+ rescue Errno::ENOENT, Errno::ENOTDIR
346
+ nil
347
+ end
348
+ end
166
349
  end
167
350
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: classy-yaml
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.2
4
+ version: 1.7.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tonksthebear
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -152,6 +152,8 @@ files:
152
152
  - lib/classy/yaml/helpers.rb
153
153
  - lib/classy/yaml/icon_helper.rb
154
154
  - lib/classy/yaml/invalid_key_error.rb
155
+ - lib/classy/yaml/lookup.rb
156
+ - lib/classy/yaml/result_cache.rb
155
157
  - lib/classy/yaml/tag_helper.rb
156
158
  - lib/classy/yaml/version.rb
157
159
  - lib/tasks/classy/yaml_tasks.rake