cataract 0.2.1 → 0.2.2

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.
@@ -43,19 +43,30 @@ module Cataract
43
43
  # - :base_path [String] Base directory for relative imports
44
44
  # @option options [Boolean] :io_exceptions (true) Whether to raise exceptions
45
45
  # on I/O errors (file not found, network errors, etc.)
46
+ # @option options [Hash] :parser ({}) Parser configuration options
47
+ # - :selector_lists [Boolean] (true) Track selector lists for W3C-compliant serialization
46
48
  def initialize(options = {})
47
49
  @options = {
48
50
  import: false,
49
- io_exceptions: true
51
+ io_exceptions: true,
52
+ parser: {}
50
53
  }.merge(options)
51
54
 
55
+ # Parser options with defaults (stored for passing to parser)
56
+ @parser_options = {
57
+ selector_lists: true
58
+ }.merge(@options[:parser] || {})
59
+
52
60
  @rules = [] # Flat array of Rule structs
53
61
  @_media_index = {} # Hash: Symbol => Array of rule IDs
62
+ @_selector_lists = {} # Hash: list_id => Array of rule IDs (for "h1, h2" grouping)
63
+ @_next_selector_list_id = 0 # Counter for selector list IDs
54
64
  @charset = nil
55
65
  @imports = [] # Array of ImportStatement objects
56
66
  @_has_nesting = nil # Set by parser (nil or boolean)
57
67
  @_last_rule_id = nil # Tracks next rule ID for add_block
58
68
  @selectors = nil # Memoized cache of selectors
69
+ @_custom_properties = nil # Memoized cache of custom properties
59
70
  end
60
71
 
61
72
  # Initialize copy for proper deep duplication.
@@ -69,7 +80,10 @@ module Cataract
69
80
  @rules = source.instance_variable_get(:@rules).dup
70
81
  @imports = source.instance_variable_get(:@imports).dup
71
82
  @_media_index = source.instance_variable_get(:@_media_index).transform_values(&:dup)
72
- @selectors = nil # Clear memoized cache
83
+ @_selector_lists = source.instance_variable_get(:@_selector_lists).transform_values(&:dup)
84
+ @_next_selector_list_id = source.instance_variable_get(:@_next_selector_list_id)
85
+ @parser_options = source.instance_variable_get(:@parser_options).dup
86
+ clear_memoized_caches
73
87
  @_hash = nil # Clear cached hash
74
88
  end
75
89
 
@@ -201,6 +215,7 @@ module Cataract
201
215
  #
202
216
  # @param property [String] CSS property name to match
203
217
  # @param value [String, nil] Optional property value to match
218
+ # @param prefix_match [Boolean] Whether to match by prefix (default: false)
204
219
  # @return [StylesheetScope] Scope with property filter applied
205
220
  #
206
221
  # @example Find all rules with color property
@@ -209,10 +224,13 @@ module Cataract
209
224
  # @example Find rules with position: absolute
210
225
  # sheet.with_property('position', 'absolute').to_a
211
226
  #
227
+ # @example Find all margin-related properties (margin, margin-top, etc.)
228
+ # sheet.with_property('margin', prefix_match: true).to_a
229
+ #
212
230
  # @example Chain with media filter
213
231
  # sheet.with_media(:screen).with_property('z-index').to_a
214
- def with_property(property, value = nil)
215
- StylesheetScope.new(self, property: property, property_value: value)
232
+ def with_property(property, value = nil, prefix_match: false)
233
+ StylesheetScope.new(self, property: property, property_value: value, property_prefix_match: prefix_match)
216
234
  end
217
235
 
218
236
  # Filter to only base rules (rules not inside any @media query).
@@ -291,6 +309,46 @@ module Cataract
291
309
  @selectors ||= @rules.map(&:selector)
292
310
  end
293
311
 
312
+ # Get all custom property (CSS variable) definitions organized by media context.
313
+ #
314
+ # Returns a hash mapping media contexts to custom property hashes.
315
+ # Custom properties are CSS variables that start with -- (e.g., --primary-color).
316
+ # The :root key contains base-level properties (not inside any @media block).
317
+ # When the same custom property is defined multiple times within the same context,
318
+ # the last definition in source order is used.
319
+ #
320
+ # @param media [Symbol, Array<Symbol>, nil] Optional filter for specific media contexts
321
+ # - nil (default) - Return all media contexts including :root
322
+ # - :root - Return only base-level properties
323
+ # - :print, :screen, etc. - Return only properties from specified media context(s)
324
+ # - [:root, :print] - Return multiple contexts
325
+ #
326
+ # @return [Hash{Symbol => Hash{String => String}}] Media contexts mapped to custom properties
327
+ #
328
+ # @example All custom properties across all contexts
329
+ # css = ':root { --color: red; } @media print { :root { --color: green; } }'
330
+ # sheet = Cataract::Stylesheet.parse(css)
331
+ # sheet.custom_properties #=> { :root => { '--color' => 'red' }, :print => { '--color' => 'green' } }
332
+ #
333
+ # @example Filter to specific media context
334
+ # sheet.custom_properties(media: :print) #=> { :print => { '--color' => 'green' } }
335
+ #
336
+ # @example Filter to multiple contexts
337
+ # sheet.custom_properties(media: [:root, :print]) #=> { :root => {...}, :print => {...} }
338
+ #
339
+ # @example Only base-level properties
340
+ # css = ':root { --spacing: 8px; }'
341
+ # sheet = Cataract::Stylesheet.parse(css)
342
+ # sheet.custom_properties #=> { :root => { '--spacing' => '8px' } }
343
+ def custom_properties(media: nil)
344
+ @_custom_properties ||= build_custom_properties
345
+ return @_custom_properties if media.nil?
346
+
347
+ # Filter by media if requested
348
+ media_array = media.is_a?(Array) ? media : [media]
349
+ @_custom_properties.slice(*media_array)
350
+ end
351
+
294
352
  # Serialize to CSS string
295
353
  #
296
354
  # Converts the stylesheet to a CSS string. Optionally filters output
@@ -323,7 +381,7 @@ module Cataract
323
381
 
324
382
  # If :all is present, return everything (no filtering)
325
383
  if which_media_array.include?(:all)
326
- Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false)
384
+ Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
327
385
  else
328
386
  # Collect all rule IDs that match the requested media types
329
387
  matching_rule_ids = []
@@ -347,7 +405,7 @@ module Cataract
347
405
 
348
406
  # C serialization with filtered data
349
407
  # Note: Filtered rules might still contain nesting, so pass the flag
350
- Cataract._stylesheet_to_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
408
+ Cataract._stylesheet_to_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false, @_selector_lists)
351
409
  end
352
410
  end
353
411
  alias to_css to_s
@@ -380,7 +438,7 @@ module Cataract
380
438
 
381
439
  # If :all is present, return everything (no filtering)
382
440
  if which_media_array.include?(:all)
383
- Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false)
441
+ Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
384
442
  else
385
443
  # Collect all rule IDs that match the requested media types
386
444
  matching_rule_ids = []
@@ -412,7 +470,7 @@ module Cataract
412
470
 
413
471
  # C serialization with filtered data
414
472
  # Note: Filtered rules might still contain nesting, so pass the flag
415
- Cataract._stylesheet_to_formatted_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
473
+ Cataract._stylesheet_to_formatted_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false, @_selector_lists)
416
474
  end
417
475
  end
418
476
 
@@ -439,7 +497,7 @@ module Cataract
439
497
  @rules.clear
440
498
  @_media_index.clear
441
499
  @charset = nil
442
- @selectors = nil # Clear memoized cache
500
+ clear_memoized_caches
443
501
  self
444
502
  end
445
503
 
@@ -600,8 +658,7 @@ module Cataract
600
658
  # Update rule IDs in remaining rules
601
659
  @rules.each_with_index { |rule, new_id| rule.id = new_id }
602
660
 
603
- # Clear memoized cache
604
- @selectors = nil
661
+ clear_memoized_caches
605
662
 
606
663
  self
607
664
  end
@@ -625,12 +682,28 @@ module Cataract
625
682
  offset = @_last_rule_id || 0
626
683
 
627
684
  # Parse CSS first (this extracts @import statements into result[:imports])
628
- result = Cataract._parse_css(css)
685
+ result = Cataract._parse_css(css, @parser_options)
686
+
687
+ # Merge selector_lists with offsetted IDs
688
+ # Must do this BEFORE updating rule IDs so we can update rule.selector_list_id
689
+ list_id_offset = @_next_selector_list_id
690
+ if result[:_selector_lists] && !result[:_selector_lists].empty?
691
+ result[:_selector_lists].each do |list_id, rule_ids|
692
+ new_list_id = list_id + list_id_offset
693
+ offsetted_rule_ids = rule_ids.map { |id| id + offset }
694
+ @_selector_lists[new_list_id] = offsetted_rule_ids
695
+ end
696
+ @_next_selector_list_id = list_id_offset + result[:_selector_lists].size
697
+ end
629
698
 
630
699
  # Merge rules with offsetted IDs
631
700
  new_rules = result[:rules]
632
701
  new_rules.each do |rule|
633
702
  rule.id += offset
703
+ # Update selector_list_id to point to offsetted list (only for Rule, not AtRule)
704
+ if rule.is_a?(Rule) && rule.selector_list_id
705
+ rule.selector_list_id += list_id_offset
706
+ end
634
707
  @rules << rule
635
708
  end
636
709
 
@@ -675,6 +748,8 @@ module Cataract
675
748
  # Track if we have any nesting (for serialization optimization)
676
749
  @_has_nesting = result[:_has_nesting]
677
750
 
751
+ clear_memoized_caches
752
+
678
753
  self
679
754
  end
680
755
 
@@ -814,8 +889,7 @@ module Cataract
814
889
  other_has_nesting = other.instance_variable_get(:@_has_nesting)
815
890
  @_has_nesting = true if other_has_nesting
816
891
 
817
- # Clear memoized cache
818
- @selectors = nil
892
+ clear_memoized_caches
819
893
 
820
894
  # Apply cascade in-place
821
895
  flatten!
@@ -1006,6 +1080,55 @@ module Cataract
1006
1080
  end
1007
1081
  end
1008
1082
 
1083
+ # Clear memoized caches that can be lazily rebuilt.
1084
+ #
1085
+ # Call this method after any operation that modifies the stylesheet's rules
1086
+ # (e.g., add_block, remove_rules, merge). These caches will automatically
1087
+ # rebuild on next access.
1088
+ #
1089
+ # Clears:
1090
+ # - @selectors: Memoized list of all selectors
1091
+ # - @_custom_properties: Memoized custom properties organized by media context
1092
+ #
1093
+ # Should not add ivars here that don't rebuild themselves (i.e. @_media_index)
1094
+ def clear_memoized_caches
1095
+ @selectors = nil
1096
+ @_custom_properties = nil
1097
+ end
1098
+
1099
+ # Build custom properties hash organized by media context
1100
+ #
1101
+ # @return [Hash{Symbol => Hash{String => String}}] Media contexts mapped to custom properties
1102
+ def build_custom_properties
1103
+ props_by_media = {}
1104
+
1105
+ # Build reverse lookup: rule_id => media_type
1106
+ rule_id_to_media = {}
1107
+ @_media_index.each do |media_type, rule_ids|
1108
+ rule_ids.each do |rule_id|
1109
+ rule_id_to_media[rule_id] = media_type
1110
+ end
1111
+ end
1112
+
1113
+ # Collect custom properties from each rule
1114
+ @rules.each do |rule|
1115
+ next unless rule.selector? # Skip at-rules
1116
+
1117
+ # Determine media context (:root for base-level rules)
1118
+ media_context = rule_id_to_media[rule.id] || :root
1119
+
1120
+ # Collect custom properties from this rule
1121
+ rule.declarations.each do |decl|
1122
+ next unless decl.custom_property?
1123
+
1124
+ props_by_media[media_context] ||= {}
1125
+ props_by_media[media_context][decl.property] = decl.value
1126
+ end
1127
+ end
1128
+
1129
+ props_by_media
1130
+ end
1131
+
1009
1132
  # Check if a rule has a declaration matching property and/or value
1010
1133
  #
1011
1134
  # @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
@@ -66,6 +66,7 @@ module Cataract
66
66
  #
67
67
  # @param property [String] CSS property name to match
68
68
  # @param value [String, nil] Optional property value to match
69
+ # @param prefix_match [Boolean] Whether to match by prefix (default: false)
69
70
  # @return [StylesheetScope] New scope with property filter applied
70
71
  #
71
72
  # @example Find rules with color property
@@ -74,8 +75,11 @@ module Cataract
74
75
  # @example Find rules with specific property value
75
76
  # sheet.with_property('position', 'absolute')
76
77
  # sheet.with_property('color', 'red')
77
- def with_property(property, value = nil)
78
- StylesheetScope.new(@stylesheet, @filters.merge(property: property, property_value: value))
78
+ #
79
+ # @example Find all margin-related properties (margin, margin-top, etc.)
80
+ # sheet.with_property('margin', prefix_match: true)
81
+ def with_property(property, value = nil, prefix_match: false)
82
+ StylesheetScope.new(@stylesheet, @filters.merge(property: property, property_value: value, property_prefix_match: prefix_match))
79
83
  end
80
84
 
81
85
  # Filter to only base rules (rules not inside any @media query).
@@ -170,8 +174,11 @@ module Cataract
170
174
  end
171
175
 
172
176
  # Property filter
173
- if @filters[:property] && !rule.has_property?(@filters[:property], @filters[:property_value])
174
- next
177
+ if @filters[:property]
178
+ prefix_match = @filters.fetch(:property_prefix_match, false)
179
+ unless rule.has_property?(@filters[:property], @filters[:property_value], prefix_match: prefix_match)
180
+ next
181
+ end
175
182
  end
176
183
 
177
184
  # At-rule type filter
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cataract
4
- VERSION = '0.2.1'
4
+ VERSION = '0.2.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cataract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Cook