cataract 0.2.3 → 0.2.5
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/.rubocop.yml +8 -3
- data/BENCHMARKS.md +50 -32
- data/CHANGELOG.md +21 -1
- data/Gemfile +3 -0
- data/ext/cataract/cataract.c +219 -112
- data/ext/cataract/cataract.h +5 -1
- data/ext/cataract/css_parser.c +875 -50
- data/ext/cataract/flatten.c +233 -91
- data/ext/cataract/shorthand_expander.c +7 -0
- data/lib/cataract/at_rule.rb +2 -1
- data/lib/cataract/constants.rb +10 -0
- data/lib/cataract/error.rb +49 -0
- data/lib/cataract/import_resolver.rb +18 -87
- data/lib/cataract/import_statement.rb +29 -5
- data/lib/cataract/media_query.rb +98 -0
- data/lib/cataract/pure/byte_constants.rb +15 -0
- data/lib/cataract/pure/flatten.rb +127 -15
- data/lib/cataract/pure/parser.rb +800 -271
- data/lib/cataract/pure/serializer.rb +216 -115
- data/lib/cataract/pure.rb +8 -7
- data/lib/cataract/rule.rb +9 -5
- data/lib/cataract/stylesheet.rb +345 -101
- data/lib/cataract/stylesheet_scope.rb +10 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +5 -8
- data/lib/tasks/profile.rake +210 -0
- metadata +5 -2
- data/lib/cataract/pure/imports.rb +0 -268
data/lib/cataract/stylesheet.rb
CHANGED
|
@@ -26,6 +26,61 @@ module Cataract
|
|
|
26
26
|
# @return [Array<Rule>] Array of parsed CSS rules
|
|
27
27
|
attr_reader :rules
|
|
28
28
|
|
|
29
|
+
# @return [Array<MediaQuery>] Array of media query objects
|
|
30
|
+
attr_reader :media_queries
|
|
31
|
+
|
|
32
|
+
# @return [Hash<Symbol, Array<Integer>>] Cached index mapping media query text to rule IDs
|
|
33
|
+
# Lazily build and return media_index.
|
|
34
|
+
# Only builds the index when first accessed, not eagerly during parse.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash{Symbol => Array<Integer>}] Hash mapping media types to rule IDs
|
|
37
|
+
def media_index
|
|
38
|
+
# If media_index is empty but we have rules with media_query_id, build it
|
|
39
|
+
if @media_index.empty? && @rules.any? { |r| r.respond_to?(:media_query_id) && r.media_query_id }
|
|
40
|
+
@media_index = {}
|
|
41
|
+
|
|
42
|
+
# First, build a reverse lookup: media_query_id => list_id (if in a list)
|
|
43
|
+
mq_id_to_list_id = {}
|
|
44
|
+
@_media_query_lists.each do |list_id, mq_ids|
|
|
45
|
+
mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@rules.each do |rule|
|
|
49
|
+
next unless rule.media_query_id
|
|
50
|
+
|
|
51
|
+
# Check if this rule's media_query_id is part of a list
|
|
52
|
+
list_id = mq_id_to_list_id[rule.media_query_id]
|
|
53
|
+
|
|
54
|
+
if list_id
|
|
55
|
+
# This rule is in a compound media query (e.g., "@media screen, print")
|
|
56
|
+
# Index it under ALL media types in the list
|
|
57
|
+
mq_ids = @_media_query_lists[list_id]
|
|
58
|
+
mq_ids.each do |mq_id|
|
|
59
|
+
mq = @media_queries[mq_id]
|
|
60
|
+
next unless mq
|
|
61
|
+
|
|
62
|
+
media_type = mq.type
|
|
63
|
+
@media_index[media_type] ||= []
|
|
64
|
+
@media_index[media_type] << rule.id
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
# Single media query - index under its type
|
|
68
|
+
mq = @media_queries[rule.media_query_id]
|
|
69
|
+
next unless mq
|
|
70
|
+
|
|
71
|
+
media_type = mq.type
|
|
72
|
+
@media_index[media_type] ||= []
|
|
73
|
+
@media_index[media_type] << rule.id
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deduplicate arrays once at the end
|
|
78
|
+
@media_index.each_value(&:uniq!)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@media_index
|
|
82
|
+
end
|
|
83
|
+
|
|
29
84
|
# @return [String, nil] The @charset declaration if present
|
|
30
85
|
attr_reader :charset
|
|
31
86
|
|
|
@@ -40,27 +95,64 @@ module Cataract
|
|
|
40
95
|
# - :allowed_schemes [Array<String>] URI schemes to allow (default: ['https'])
|
|
41
96
|
# - :extensions [Array<String>] File extensions to allow (default: ['css'])
|
|
42
97
|
# - :max_depth [Integer] Maximum import nesting (default: 5)
|
|
43
|
-
# - :base_path [String] Base directory for relative imports
|
|
44
98
|
# @option options [Boolean] :io_exceptions (true) Whether to raise exceptions
|
|
45
99
|
# on I/O errors (file not found, network errors, etc.)
|
|
100
|
+
# @option options [String] :base_uri (nil) Base URI for resolving relative URLs
|
|
101
|
+
# and @import paths. Used for both URL conversion and import resolution.
|
|
102
|
+
# @option options [String] :base_dir (nil) Base directory for resolving local
|
|
103
|
+
# file @import paths.
|
|
104
|
+
# @option options [Boolean] :absolute_paths (false) Convert relative URLs in
|
|
105
|
+
# url() values to absolute URLs using base_uri.
|
|
106
|
+
# @option options [Proc] :uri_resolver (nil) Custom proc for resolving relative URIs.
|
|
107
|
+
# Takes (base_uri, relative_uri) and returns absolute URI string.
|
|
108
|
+
# Defaults to using Ruby's URI.parse(base).merge(relative).to_s
|
|
46
109
|
# @option options [Hash] :parser ({}) Parser configuration options
|
|
47
110
|
# - :selector_lists [Boolean] (true) Track selector lists for W3C-compliant serialization
|
|
48
111
|
def initialize(options = {})
|
|
112
|
+
# Type validation
|
|
113
|
+
raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
# Support :imports as alias for :import (backwards compatibility)
|
|
116
|
+
options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
|
|
117
|
+
|
|
49
118
|
@options = {
|
|
50
119
|
import: false,
|
|
51
120
|
io_exceptions: true,
|
|
52
|
-
|
|
121
|
+
base_uri: nil,
|
|
122
|
+
base_dir: nil,
|
|
123
|
+
absolute_paths: false,
|
|
124
|
+
uri_resolver: nil,
|
|
125
|
+
parser: {},
|
|
126
|
+
raise_parse_errors: false
|
|
53
127
|
}.merge(options)
|
|
54
128
|
|
|
129
|
+
# Type validation for specific options
|
|
130
|
+
if @options[:import_fetcher] && !@options[:import_fetcher].respond_to?(:call)
|
|
131
|
+
raise TypeError, "import_fetcher must be a Proc or callable, got #{@options[:import_fetcher].class}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if @options[:base_uri] && !@options[:base_uri].is_a?(String)
|
|
135
|
+
raise TypeError, "base_uri must be a String, got #{@options[:base_uri].class}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @options[:uri_resolver] && !@options[:uri_resolver].respond_to?(:call)
|
|
139
|
+
raise TypeError, "uri_resolver must be a Proc or callable, got #{@options[:uri_resolver].class}"
|
|
140
|
+
end
|
|
141
|
+
|
|
55
142
|
# Parser options with defaults (stored for passing to parser)
|
|
56
143
|
@parser_options = {
|
|
57
|
-
selector_lists: true
|
|
144
|
+
selector_lists: true,
|
|
145
|
+
raise_parse_errors: @options[:raise_parse_errors]
|
|
58
146
|
}.merge(@options[:parser] || {})
|
|
59
147
|
|
|
60
148
|
@rules = [] # Flat array of Rule structs
|
|
61
|
-
@
|
|
149
|
+
@media_queries = [] # Array of MediaQuery objects
|
|
150
|
+
@_next_media_query_id = 0 # Counter for MediaQuery IDs
|
|
151
|
+
@media_index = {} # Hash: Symbol => Array of rule IDs (cached index, can be rebuilt from rules)
|
|
62
152
|
@_selector_lists = {} # Hash: list_id => Array of rule IDs (for "h1, h2" grouping)
|
|
63
153
|
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
154
|
+
@_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print" grouping)
|
|
155
|
+
@_next_media_query_list_id = 0 # Counter for media query list IDs
|
|
64
156
|
@charset = nil
|
|
65
157
|
@imports = [] # Array of ImportStatement objects
|
|
66
158
|
@_has_nesting = nil # Set by parser (nil or boolean)
|
|
@@ -78,10 +170,14 @@ module Cataract
|
|
|
78
170
|
def initialize_copy(source)
|
|
79
171
|
super
|
|
80
172
|
@rules = source.instance_variable_get(:@rules).dup
|
|
173
|
+
@media_queries = source.instance_variable_get(:@media_queries).dup
|
|
174
|
+
@_next_media_query_id = source.instance_variable_get(:@_next_media_query_id)
|
|
175
|
+
@media_index = source.instance_variable_get(:@media_index).transform_values(&:dup)
|
|
81
176
|
@imports = source.instance_variable_get(:@imports).dup
|
|
82
|
-
@_media_index = source.instance_variable_get(:@_media_index).transform_values(&:dup)
|
|
83
177
|
@_selector_lists = source.instance_variable_get(:@_selector_lists).transform_values(&:dup)
|
|
84
178
|
@_next_selector_list_id = source.instance_variable_get(:@_next_selector_list_id)
|
|
179
|
+
@_media_query_lists = source.instance_variable_get(:@_media_query_lists).transform_values(&:dup)
|
|
180
|
+
@_next_media_query_list_id = source.instance_variable_get(:@_next_media_query_list_id)
|
|
85
181
|
@parser_options = source.instance_variable_get(:@parser_options).dup
|
|
86
182
|
clear_memoized_caches
|
|
87
183
|
@_hash = nil # Clear cached hash
|
|
@@ -291,17 +387,10 @@ module Cataract
|
|
|
291
387
|
# @return [Array<Rule>] Rules with no media query
|
|
292
388
|
def base_rules
|
|
293
389
|
# Rules not in any media_index entry
|
|
294
|
-
media_rule_ids =
|
|
390
|
+
media_rule_ids = media_index.values.flatten.uniq
|
|
295
391
|
@rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
|
|
296
392
|
end
|
|
297
393
|
|
|
298
|
-
# Get all unique media query symbols
|
|
299
|
-
#
|
|
300
|
-
# @return [Array<Symbol>] Array of unique media query symbols
|
|
301
|
-
def media_queries
|
|
302
|
-
@_media_index.keys
|
|
303
|
-
end
|
|
304
|
-
|
|
305
394
|
# Get all selectors
|
|
306
395
|
#
|
|
307
396
|
# @return [Array<String>] Array of all selectors
|
|
@@ -381,13 +470,14 @@ module Cataract
|
|
|
381
470
|
|
|
382
471
|
# If :all is present, return everything (no filtering)
|
|
383
472
|
if which_media_array.include?(:all)
|
|
384
|
-
Cataract.
|
|
473
|
+
Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
385
474
|
else
|
|
386
475
|
# Collect all rule IDs that match the requested media types
|
|
387
476
|
matching_rule_ids = []
|
|
477
|
+
mi = media_index # Build media_index if needed
|
|
388
478
|
which_media_array.each do |media_sym|
|
|
389
|
-
if
|
|
390
|
-
matching_rule_ids.concat(
|
|
479
|
+
if mi[media_sym]
|
|
480
|
+
matching_rule_ids.concat(mi[media_sym])
|
|
391
481
|
end
|
|
392
482
|
end
|
|
393
483
|
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
@@ -395,17 +485,8 @@ module Cataract
|
|
|
395
485
|
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
396
486
|
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
397
487
|
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
which_media_array.each do |media_sym|
|
|
401
|
-
if @_media_index[media_sym]
|
|
402
|
-
filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
# C serialization with filtered data
|
|
407
|
-
# Note: Filtered rules might still contain nesting, so pass the flag
|
|
408
|
-
Cataract._stylesheet_to_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false, @_selector_lists)
|
|
488
|
+
# Serialize with filtered data
|
|
489
|
+
Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
409
490
|
end
|
|
410
491
|
end
|
|
411
492
|
alias to_css to_s
|
|
@@ -438,21 +519,22 @@ module Cataract
|
|
|
438
519
|
|
|
439
520
|
# If :all is present, return everything (no filtering)
|
|
440
521
|
if which_media_array.include?(:all)
|
|
441
|
-
Cataract.
|
|
522
|
+
Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
442
523
|
else
|
|
443
524
|
# Collect all rule IDs that match the requested media types
|
|
444
525
|
matching_rule_ids = []
|
|
526
|
+
mi = media_index # Build media_index if needed
|
|
445
527
|
|
|
446
528
|
# Include rules not in any media query (they apply to all media)
|
|
447
|
-
media_rule_ids =
|
|
529
|
+
media_rule_ids = mi.values.flatten.uniq
|
|
448
530
|
all_rule_ids = (0...@rules.length).to_a
|
|
449
531
|
non_media_rule_ids = all_rule_ids - media_rule_ids
|
|
450
532
|
matching_rule_ids.concat(non_media_rule_ids)
|
|
451
533
|
|
|
452
534
|
# Include rules from requested media types
|
|
453
535
|
which_media_array.each do |media_sym|
|
|
454
|
-
if
|
|
455
|
-
matching_rule_ids.concat(
|
|
536
|
+
if mi[media_sym]
|
|
537
|
+
matching_rule_ids.concat(mi[media_sym])
|
|
456
538
|
end
|
|
457
539
|
end
|
|
458
540
|
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
@@ -460,17 +542,8 @@ module Cataract
|
|
|
460
542
|
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
461
543
|
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
462
544
|
|
|
463
|
-
#
|
|
464
|
-
|
|
465
|
-
which_media_array.each do |media_sym|
|
|
466
|
-
if @_media_index[media_sym]
|
|
467
|
-
filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids
|
|
468
|
-
end
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# C serialization with filtered data
|
|
472
|
-
# Note: Filtered rules might still contain nesting, so pass the flag
|
|
473
|
-
Cataract._stylesheet_to_formatted_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false, @_selector_lists)
|
|
545
|
+
# Serialize with filtered data
|
|
546
|
+
Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
474
547
|
end
|
|
475
548
|
end
|
|
476
549
|
|
|
@@ -495,7 +568,7 @@ module Cataract
|
|
|
495
568
|
# @return [self] Returns self for method chaining
|
|
496
569
|
def clear!
|
|
497
570
|
@rules.clear
|
|
498
|
-
@
|
|
571
|
+
@media_index.clear
|
|
499
572
|
@charset = nil
|
|
500
573
|
clear_memoized_caches
|
|
501
574
|
self
|
|
@@ -624,16 +697,14 @@ module Cataract
|
|
|
624
697
|
|
|
625
698
|
# Check media type match if filter is specified
|
|
626
699
|
if filter_media
|
|
627
|
-
rule_media_types =
|
|
628
|
-
# Extract individual media types from complex queries
|
|
629
|
-
individual_types = rule_media_types.flat_map { |key| Cataract.parse_media_types(key) }.uniq
|
|
700
|
+
rule_media_types = media_index.select { |_media, ids| ids.include?(rule_id) }.keys
|
|
630
701
|
|
|
631
702
|
# If rule is not in any media query (base rule), skip unless :all is specified
|
|
632
|
-
if
|
|
703
|
+
if rule_media_types.empty?
|
|
633
704
|
next unless filter_media.include?(:all)
|
|
634
705
|
else
|
|
635
706
|
# Check if rule's media types intersect with filter
|
|
636
|
-
next unless
|
|
707
|
+
next unless rule_media_types.intersect?(filter_media)
|
|
637
708
|
end
|
|
638
709
|
end
|
|
639
710
|
|
|
@@ -645,7 +716,7 @@ module Cataract
|
|
|
645
716
|
@rules.delete_at(rule_id)
|
|
646
717
|
|
|
647
718
|
# Remove from media_index and update IDs for rules after this one
|
|
648
|
-
@
|
|
719
|
+
@media_index.each_value do |ids|
|
|
649
720
|
ids.delete(rule_id)
|
|
650
721
|
# Decrement IDs greater than removed ID
|
|
651
722
|
ids.map! { |id| id > rule_id ? id - 1 : id }
|
|
@@ -653,7 +724,11 @@ module Cataract
|
|
|
653
724
|
end
|
|
654
725
|
|
|
655
726
|
# Clean up empty media_index entries
|
|
656
|
-
@
|
|
727
|
+
@media_index.delete_if { |_media, ids| ids.empty? }
|
|
728
|
+
|
|
729
|
+
# Clean up unused MediaQuery objects (those not referenced by any rule)
|
|
730
|
+
used_mq_ids = @rules.filter_map { |r| r.media_query_id if r.is_a?(Rule) }.to_set
|
|
731
|
+
@media_queries.select! { |mq| used_mq_ids.include?(mq.id) }
|
|
657
732
|
|
|
658
733
|
# Update rule IDs in remaining rules
|
|
659
734
|
@rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
@@ -668,8 +743,11 @@ module Cataract
|
|
|
668
743
|
# @param css [String] CSS string to add
|
|
669
744
|
# @param fix_braces [Boolean] Automatically close missing braces
|
|
670
745
|
# @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
|
|
746
|
+
# @param base_uri [String, nil] Override constructor's base_uri for this block
|
|
747
|
+
# @param base_dir [String, nil] Override constructor's base_dir for this block
|
|
748
|
+
# @param absolute_paths [Boolean, nil] Override constructor's absolute_paths for this block
|
|
671
749
|
# @return [self] Returns self for method chaining
|
|
672
|
-
def add_block(css, fix_braces: false, media_types: nil)
|
|
750
|
+
def add_block(css, fix_braces: false, media_types: nil, base_uri: nil, base_dir: nil, absolute_paths: nil)
|
|
673
751
|
css += ' }' if fix_braces && !css.strip.end_with?('}')
|
|
674
752
|
|
|
675
753
|
# Convenience wrapper: wrap in @media if media_types specified
|
|
@@ -678,14 +756,26 @@ module Cataract
|
|
|
678
756
|
css = "@media #{media_list} { #{css} }"
|
|
679
757
|
end
|
|
680
758
|
|
|
759
|
+
# Determine effective options (per-call overrides or constructor defaults)
|
|
760
|
+
effective_base_uri = base_uri || @options[:base_uri]
|
|
761
|
+
effective_base_dir = base_dir || @options[:base_dir]
|
|
762
|
+
effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
|
|
763
|
+
|
|
681
764
|
# Get current rule ID offset
|
|
682
765
|
offset = @_last_rule_id || 0
|
|
683
766
|
|
|
767
|
+
# Build parser options with URL conversion settings
|
|
768
|
+
parse_options = @parser_options.dup
|
|
769
|
+
if effective_absolute_paths && effective_base_uri
|
|
770
|
+
parse_options[:base_uri] = effective_base_uri
|
|
771
|
+
parse_options[:absolute_paths] = true
|
|
772
|
+
parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
773
|
+
end
|
|
774
|
+
|
|
684
775
|
# Parse CSS first (this extracts @import statements into result[:imports])
|
|
685
|
-
result = Cataract._parse_css(css,
|
|
776
|
+
result = Cataract._parse_css(css, parse_options)
|
|
686
777
|
|
|
687
778
|
# Merge selector_lists with offsetted IDs
|
|
688
|
-
# Must do this BEFORE updating rule IDs so we can update rule.selector_list_id
|
|
689
779
|
list_id_offset = @_next_selector_list_id
|
|
690
780
|
if result[:_selector_lists] && !result[:_selector_lists].empty?
|
|
691
781
|
result[:_selector_lists].each do |list_id, rule_ids|
|
|
@@ -696,6 +786,18 @@ module Cataract
|
|
|
696
786
|
@_next_selector_list_id = list_id_offset + result[:_selector_lists].size
|
|
697
787
|
end
|
|
698
788
|
|
|
789
|
+
# Merge media_query_lists with offsetted IDs
|
|
790
|
+
media_query_id_offset = @_next_media_query_id
|
|
791
|
+
mq_list_id_offset = @_next_media_query_list_id
|
|
792
|
+
if result[:_media_query_lists] && !result[:_media_query_lists].empty?
|
|
793
|
+
result[:_media_query_lists].each do |list_id, mq_ids|
|
|
794
|
+
new_list_id = list_id + mq_list_id_offset
|
|
795
|
+
offsetted_mq_ids = mq_ids.map { |id| id + media_query_id_offset }
|
|
796
|
+
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
797
|
+
end
|
|
798
|
+
@_next_media_query_list_id = mq_list_id_offset + result[:_media_query_lists].size
|
|
799
|
+
end
|
|
800
|
+
|
|
699
801
|
# Merge rules with offsetted IDs
|
|
700
802
|
new_rules = result[:rules]
|
|
701
803
|
new_rules.each do |rule|
|
|
@@ -704,19 +806,32 @@ module Cataract
|
|
|
704
806
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
705
807
|
rule.selector_list_id += list_id_offset
|
|
706
808
|
end
|
|
809
|
+
# Update media_query_id to point to offsetted MediaQuery
|
|
810
|
+
if rule.is_a?(Rule) && rule.media_query_id
|
|
811
|
+
rule.media_query_id += media_query_id_offset
|
|
812
|
+
end
|
|
707
813
|
@rules << rule
|
|
708
814
|
end
|
|
709
815
|
|
|
710
816
|
# Merge media_index with offsetted IDs
|
|
711
817
|
result[:_media_index].each do |media_sym, rule_ids|
|
|
712
818
|
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
713
|
-
if @
|
|
714
|
-
@
|
|
819
|
+
if @media_index[media_sym]
|
|
820
|
+
@media_index[media_sym].concat(offsetted_ids)
|
|
715
821
|
else
|
|
716
|
-
@
|
|
822
|
+
@media_index[media_sym] = offsetted_ids
|
|
717
823
|
end
|
|
718
824
|
end
|
|
719
825
|
|
|
826
|
+
# Merge media_queries with offsetted IDs
|
|
827
|
+
if result[:media_queries]
|
|
828
|
+
result[:media_queries].each do |mq|
|
|
829
|
+
mq.id += media_query_id_offset
|
|
830
|
+
@media_queries << mq
|
|
831
|
+
end
|
|
832
|
+
@_next_media_query_id += result[:media_queries].length
|
|
833
|
+
end
|
|
834
|
+
|
|
720
835
|
# Update last rule ID
|
|
721
836
|
@_last_rule_id = offset + new_rules.length
|
|
722
837
|
|
|
@@ -725,6 +840,10 @@ module Cataract
|
|
|
725
840
|
new_imports = result[:imports]
|
|
726
841
|
new_imports.each do |import|
|
|
727
842
|
import.id += offset
|
|
843
|
+
# Update media_query_id to point to offsetted MediaQuery
|
|
844
|
+
if import.media_query_id
|
|
845
|
+
import.media_query_id += media_query_id_offset
|
|
846
|
+
end
|
|
728
847
|
@imports << import
|
|
729
848
|
end
|
|
730
849
|
|
|
@@ -738,7 +857,13 @@ module Cataract
|
|
|
738
857
|
imported_urls = []
|
|
739
858
|
depth = 0
|
|
740
859
|
end
|
|
741
|
-
|
|
860
|
+
|
|
861
|
+
# Build import options with base_uri/base_dir for URL resolution
|
|
862
|
+
import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
|
|
863
|
+
import_opts[:base_uri] = effective_base_uri if effective_base_uri
|
|
864
|
+
import_opts[:base_path] = effective_base_dir if effective_base_dir
|
|
865
|
+
|
|
866
|
+
resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
|
|
742
867
|
end
|
|
743
868
|
end
|
|
744
869
|
|
|
@@ -799,7 +924,7 @@ module Cataract
|
|
|
799
924
|
def ==(other)
|
|
800
925
|
return false unless other.is_a?(Stylesheet)
|
|
801
926
|
return false unless rules == other.rules
|
|
802
|
-
return false unless @
|
|
927
|
+
return false unless @media_queries == other.instance_variable_get(:@media_queries)
|
|
803
928
|
|
|
804
929
|
true
|
|
805
930
|
end
|
|
@@ -807,11 +932,11 @@ module Cataract
|
|
|
807
932
|
|
|
808
933
|
# Generate hash code for this stylesheet.
|
|
809
934
|
#
|
|
810
|
-
# Hash is based on rules and
|
|
935
|
+
# Hash is based on rules and media_queries to match equality semantics.
|
|
811
936
|
#
|
|
812
937
|
# @return [Integer] hash code
|
|
813
938
|
def hash
|
|
814
|
-
@_hash ||= [self.class, rules, @
|
|
939
|
+
@_hash ||= [self.class, rules, @media_queries].hash # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
815
940
|
end
|
|
816
941
|
|
|
817
942
|
# Flatten all rules in this stylesheet according to CSS cascade rules.
|
|
@@ -843,7 +968,7 @@ module Cataract
|
|
|
843
968
|
def flatten!
|
|
844
969
|
flattened = Cataract.flatten(self)
|
|
845
970
|
@rules = flattened.instance_variable_get(:@rules)
|
|
846
|
-
@
|
|
971
|
+
@media_index = flattened.instance_variable_get(:@media_index)
|
|
847
972
|
@_has_nesting = flattened.instance_variable_get(:@_has_nesting)
|
|
848
973
|
self
|
|
849
974
|
end
|
|
@@ -876,12 +1001,12 @@ module Cataract
|
|
|
876
1001
|
end
|
|
877
1002
|
|
|
878
1003
|
# Merge media_index with offsetted IDs
|
|
879
|
-
other.instance_variable_get(:@
|
|
1004
|
+
other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
|
|
880
1005
|
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
881
|
-
if @
|
|
882
|
-
@
|
|
1006
|
+
if @media_index[media_sym]
|
|
1007
|
+
@media_index[media_sym].concat(offsetted_ids)
|
|
883
1008
|
else
|
|
884
|
-
@
|
|
1009
|
+
@media_index[media_sym] = offsetted_ids
|
|
885
1010
|
end
|
|
886
1011
|
end
|
|
887
1012
|
|
|
@@ -932,7 +1057,7 @@ module Cataract
|
|
|
932
1057
|
result.rules.delete_at(idx)
|
|
933
1058
|
|
|
934
1059
|
# Update media_index: remove this rule ID and decrement higher IDs
|
|
935
|
-
result.instance_variable_get(:@
|
|
1060
|
+
result.instance_variable_get(:@media_index).each_value do |ids|
|
|
936
1061
|
ids.delete(idx)
|
|
937
1062
|
ids.map! { |id| id > idx ? id - 1 : id }
|
|
938
1063
|
end
|
|
@@ -942,7 +1067,43 @@ module Cataract
|
|
|
942
1067
|
result.rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
943
1068
|
|
|
944
1069
|
# Clean up empty media_index entries
|
|
945
|
-
result.instance_variable_get(:@
|
|
1070
|
+
result.instance_variable_get(:@media_index).delete_if { |_media, ids| ids.empty? }
|
|
1071
|
+
|
|
1072
|
+
# Clean up unused MediaQuery objects and rebuild ID mapping
|
|
1073
|
+
used_mq_ids = Set.new
|
|
1074
|
+
result.rules.each do |rule|
|
|
1075
|
+
used_mq_ids << rule.media_query_id if rule.respond_to?(:media_query_id) && rule.media_query_id
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
# Build old_id => new_id mapping
|
|
1079
|
+
# Keep MediaQuery objects that are used, maintaining their IDs
|
|
1080
|
+
old_to_new_mq_id = {}
|
|
1081
|
+
kept_mqs = []
|
|
1082
|
+
result.instance_variable_get(:@media_queries).each do |mq|
|
|
1083
|
+
next unless used_mq_ids.include?(mq.id)
|
|
1084
|
+
|
|
1085
|
+
old_to_new_mq_id[mq.id] = kept_mqs.size
|
|
1086
|
+
mq.id = kept_mqs.size
|
|
1087
|
+
kept_mqs << mq
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Replace media_queries array with kept ones
|
|
1091
|
+
result.instance_variable_set(:@media_queries, kept_mqs)
|
|
1092
|
+
|
|
1093
|
+
# Update media_query_id references in rules
|
|
1094
|
+
result.rules.each do |rule|
|
|
1095
|
+
if rule.respond_to?(:media_query_id) && rule.media_query_id
|
|
1096
|
+
rule.media_query_id = old_to_new_mq_id[rule.media_query_id]
|
|
1097
|
+
end
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
# Update media_query_lists with new IDs
|
|
1101
|
+
result.instance_variable_get(:@_media_query_lists).each_value do |mq_ids|
|
|
1102
|
+
mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact!
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
# Clean up media_query_lists that are now empty
|
|
1106
|
+
result.instance_variable_get(:@_media_query_lists).delete_if { |_list_id, mq_ids| mq_ids.empty? }
|
|
946
1107
|
|
|
947
1108
|
# Clear memoized cache
|
|
948
1109
|
result.instance_variable_set(:@selectors, nil)
|
|
@@ -982,7 +1143,7 @@ module Cataract
|
|
|
982
1143
|
next if import.resolved # Skip already resolved imports
|
|
983
1144
|
|
|
984
1145
|
url = import.url
|
|
985
|
-
|
|
1146
|
+
import_media_query_id = import.media_query_id
|
|
986
1147
|
|
|
987
1148
|
# Validate URL
|
|
988
1149
|
ImportResolver.validate_url(url, opts)
|
|
@@ -996,36 +1157,68 @@ module Cataract
|
|
|
996
1157
|
# Parse imported CSS recursively
|
|
997
1158
|
imported_urls_copy = imported_urls.dup
|
|
998
1159
|
imported_urls_copy << url
|
|
999
|
-
|
|
1160
|
+
|
|
1161
|
+
# Determine the base URI for the imported file
|
|
1162
|
+
# This becomes the new base for resolving relative URLs in the imported CSS
|
|
1163
|
+
imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
|
|
1164
|
+
|
|
1165
|
+
# Build parse options for imported CSS
|
|
1166
|
+
parse_opts = {
|
|
1167
|
+
import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
|
|
1168
|
+
parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
# If URL conversion is enabled (base_uri present), enable it for imported files too
|
|
1172
|
+
if opts[:base_uri]
|
|
1173
|
+
parse_opts[:absolute_paths] = true
|
|
1174
|
+
parse_opts[:base_uri] = imported_base_uri
|
|
1175
|
+
parse_opts[:uri_resolver] = opts[:uri_resolver]
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Pass parent import's media query context to parser so nested imports can combine
|
|
1179
|
+
if import_media_query_id
|
|
1180
|
+
parent_mq = @media_queries[import_media_query_id]
|
|
1181
|
+
parse_opts[:parser][:parent_import_media_type] = parent_mq.type
|
|
1182
|
+
parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
|
|
1000
1186
|
|
|
1001
1187
|
# Wrap rules in @media if import had media query
|
|
1002
|
-
if
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
# A rule may be in multiple media entries (e.g., :screen and :"screen and (min-width: 768px)")
|
|
1006
|
-
# We want the most specific one (longest string)
|
|
1007
|
-
# TODO: Extract this logic to a helper method to keep it consistent across codebase
|
|
1008
|
-
rule_media = nil
|
|
1009
|
-
imported_sheet.instance_variable_get(:@_media_index).each do |m, ids|
|
|
1010
|
-
# Keep the longest/most specific media query
|
|
1011
|
-
if ids.include?(rule.id) && (rule_media.nil? || m.to_s.length > rule_media.to_s.length)
|
|
1012
|
-
rule_media = m
|
|
1013
|
-
end
|
|
1014
|
-
end
|
|
1188
|
+
if import_media_query_id
|
|
1189
|
+
# Get the import's MediaQuery object
|
|
1190
|
+
import_mq = @media_queries[import_media_query_id]
|
|
1015
1191
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1192
|
+
imported_sheet.rules.each do |rule|
|
|
1193
|
+
next unless rule.is_a?(Rule)
|
|
1194
|
+
|
|
1195
|
+
if rule.media_query_id
|
|
1196
|
+
# Rule already has a media query - need to combine them
|
|
1197
|
+
# Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
|
|
1198
|
+
# Result: screen and (max-width: 768px)
|
|
1199
|
+
existing_mq = imported_sheet.media_queries[rule.media_query_id]
|
|
1200
|
+
|
|
1201
|
+
# Parse combined media query to extract type and conditions
|
|
1202
|
+
# The type is always the import's type (leftmost)
|
|
1203
|
+
combined_type = import_mq.type
|
|
1204
|
+
combined_conditions = if import_mq.conditions && existing_mq.conditions
|
|
1205
|
+
"#{import_mq.conditions} and #{existing_mq.conditions}"
|
|
1206
|
+
elsif import_mq.conditions
|
|
1207
|
+
"#{import_mq.conditions} and #{existing_mq.text}"
|
|
1208
|
+
elsif existing_mq.conditions
|
|
1209
|
+
existing_mq.conditions
|
|
1210
|
+
else
|
|
1211
|
+
existing_mq.text
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
# Create combined MediaQuery
|
|
1215
|
+
combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
|
|
1216
|
+
@media_queries << combined_mq
|
|
1217
|
+
rule.media_query_id = @_next_media_query_id
|
|
1218
|
+
@_next_media_query_id += 1
|
|
1027
1219
|
else
|
|
1028
|
-
|
|
1220
|
+
# Rule has no media query - just assign the import's media query
|
|
1221
|
+
rule.media_query_id = import_media_query_id
|
|
1029
1222
|
end
|
|
1030
1223
|
end
|
|
1031
1224
|
end
|
|
@@ -1033,17 +1226,41 @@ module Cataract
|
|
|
1033
1226
|
# Merge imported rules into this stylesheet
|
|
1034
1227
|
# Insert at current position (before any remaining local rules)
|
|
1035
1228
|
insert_position = import.id
|
|
1229
|
+
|
|
1230
|
+
# Insert rules without modifying IDs (will renumber everything after all imports resolved)
|
|
1036
1231
|
imported_sheet.rules.each_with_index do |rule, idx|
|
|
1037
1232
|
@rules.insert(insert_position + idx, rule)
|
|
1038
1233
|
end
|
|
1039
1234
|
|
|
1040
1235
|
# Merge media index
|
|
1041
|
-
imported_sheet.instance_variable_get(:@
|
|
1042
|
-
if @
|
|
1043
|
-
@
|
|
1236
|
+
imported_sheet.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
|
|
1237
|
+
if @media_index[media_sym]
|
|
1238
|
+
@media_index[media_sym].concat(rule_ids)
|
|
1044
1239
|
else
|
|
1045
|
-
@
|
|
1240
|
+
@media_index[media_sym] = rule_ids.dup
|
|
1241
|
+
end
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
# Merge selector_lists with offsetted IDs
|
|
1245
|
+
list_id_offset = @_next_selector_list_id
|
|
1246
|
+
imported_selector_lists = imported_sheet.instance_variable_get(:@_selector_lists)
|
|
1247
|
+
if imported_selector_lists && !imported_selector_lists.empty?
|
|
1248
|
+
imported_selector_lists.each do |list_id, rule_ids|
|
|
1249
|
+
new_list_id = list_id + list_id_offset
|
|
1250
|
+
@_selector_lists[new_list_id] = rule_ids.dup
|
|
1251
|
+
end
|
|
1252
|
+
@_next_selector_list_id = list_id_offset + imported_selector_lists.size
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
# Merge media_query_lists with offsetted IDs
|
|
1256
|
+
mq_list_id_offset = @_next_media_query_list_id
|
|
1257
|
+
imported_mq_lists = imported_sheet.instance_variable_get(:@_media_query_lists)
|
|
1258
|
+
if imported_mq_lists && !imported_mq_lists.empty?
|
|
1259
|
+
imported_mq_lists.each do |list_id, mq_ids|
|
|
1260
|
+
new_list_id = list_id + mq_list_id_offset
|
|
1261
|
+
@_media_query_lists[new_list_id] = mq_ids.dup
|
|
1046
1262
|
end
|
|
1263
|
+
@_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
|
|
1047
1264
|
end
|
|
1048
1265
|
|
|
1049
1266
|
# Merge charset (first one wins per CSS spec)
|
|
@@ -1052,6 +1269,33 @@ module Cataract
|
|
|
1052
1269
|
# Mark as resolved
|
|
1053
1270
|
import.resolved = true
|
|
1054
1271
|
end
|
|
1272
|
+
|
|
1273
|
+
# Renumber all rule IDs to be sequential in document order
|
|
1274
|
+
# This is O(n) and very fast (~1ms for 30k rules)
|
|
1275
|
+
# Only needed if we actually resolved imports
|
|
1276
|
+
return unless imports.length > 0
|
|
1277
|
+
|
|
1278
|
+
# Single-pass renumbering: build old->new mapping while renumbering
|
|
1279
|
+
old_to_new_id = {}
|
|
1280
|
+
@rules.each_with_index do |rule, new_idx|
|
|
1281
|
+
if rule.is_a?(Rule) || rule.is_a?(ImportStatement)
|
|
1282
|
+
old_to_new_id[rule.id] = new_idx
|
|
1283
|
+
rule.id = new_idx
|
|
1284
|
+
end
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# Update rule IDs in selector_lists (only if we have any)
|
|
1288
|
+
unless @_selector_lists.empty?
|
|
1289
|
+
@_selector_lists.each do |list_id, rule_ids|
|
|
1290
|
+
@_selector_lists[list_id] = rule_ids.map { |old_id| old_to_new_id[old_id] }
|
|
1291
|
+
end
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
# Update @_last_rule_id to reflect final count
|
|
1295
|
+
@_last_rule_id = @rules.length
|
|
1296
|
+
|
|
1297
|
+
# Clear media_index so it gets rebuilt lazily when accessed
|
|
1298
|
+
@media_index = {}
|
|
1055
1299
|
end
|
|
1056
1300
|
|
|
1057
1301
|
# Check if a rule matches any of the requested media queries
|
|
@@ -1060,7 +1304,7 @@ module Cataract
|
|
|
1060
1304
|
# @param query_media [Array<Symbol>] Media types to match
|
|
1061
1305
|
# @return [Boolean] true if rule appears in any of the requested media index entries
|
|
1062
1306
|
def rule_matches_media?(rule_id, query_media)
|
|
1063
|
-
query_media.any? { |m|
|
|
1307
|
+
query_media.any? { |m| media_index[m]&.include?(rule_id) }
|
|
1064
1308
|
end
|
|
1065
1309
|
|
|
1066
1310
|
# Check if a rule matches the specificity filter
|
|
@@ -1090,7 +1334,7 @@ module Cataract
|
|
|
1090
1334
|
# - @selectors: Memoized list of all selectors
|
|
1091
1335
|
# - @_custom_properties: Memoized custom properties organized by media context
|
|
1092
1336
|
#
|
|
1093
|
-
# Should not add ivars here that don't rebuild themselves (i.e. @
|
|
1337
|
+
# Should not add ivars here that don't rebuild themselves (i.e. @media_index)
|
|
1094
1338
|
def clear_memoized_caches
|
|
1095
1339
|
@selectors = nil
|
|
1096
1340
|
@_custom_properties = nil
|
|
@@ -1104,7 +1348,7 @@ module Cataract
|
|
|
1104
1348
|
|
|
1105
1349
|
# Build reverse lookup: rule_id => media_type
|
|
1106
1350
|
rule_id_to_media = {}
|
|
1107
|
-
|
|
1351
|
+
media_index.each do |media_type, rule_ids|
|
|
1108
1352
|
rule_ids.each do |rule_id|
|
|
1109
1353
|
rule_id_to_media[rule_id] = media_type
|
|
1110
1354
|
end
|