cataract 0.2.0 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.rubocop.yml +2 -0
- data/BENCHMARKS.md +41 -38
- data/CHANGELOG.md +17 -0
- data/README.md +9 -3
- data/ext/cataract/cataract.c +376 -89
- data/ext/cataract/cataract.h +8 -3
- data/ext/cataract/css_parser.c +125 -11
- data/ext/cataract/flatten.c +271 -16
- data/lib/cataract/declaration.rb +19 -0
- data/lib/cataract/pure/flatten.rb +103 -8
- data/lib/cataract/pure/parser.rb +203 -139
- data/lib/cataract/pure/serializer.rb +220 -115
- data/lib/cataract/pure.rb +4 -2
- data/lib/cataract/rule.rb +39 -3
- data/lib/cataract/stylesheet.rb +137 -14
- data/lib/cataract/stylesheet_scope.rb +11 -4
- data/lib/cataract/version.rb +1 -1
- metadata +1 -1
data/lib/cataract/stylesheet.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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]
|
|
174
|
-
|
|
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
|
data/lib/cataract/version.rb
CHANGED