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.
@@ -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
- parser: {}
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
- @_media_index = {} # Hash: Symbol => Array of rule IDs
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 = @_media_index.values.flatten.uniq
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._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
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 @_media_index[media_sym]
390
- matching_rule_ids.concat(@_media_index[media_sym])
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
- # Build filtered media_index (keep original IDs, just filter to included rules)
399
- filtered_media_index = {}
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._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
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 = @_media_index.values.flatten.uniq
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 @_media_index[media_sym]
455
- matching_rule_ids.concat(@_media_index[media_sym])
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
- # Build filtered media_index (keep original IDs, just filter to included rules)
464
- filtered_media_index = {}
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
- @_media_index.clear
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 = @_media_index.select { |_media, ids| ids.include?(rule_id) }.keys
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 individual_types.empty?
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 individual_types.intersect?(filter_media)
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
- @_media_index.each_value do |ids|
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
- @_media_index.delete_if { |_media, ids| ids.empty? }
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, @parser_options)
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 @_media_index[media_sym]
714
- @_media_index[media_sym].concat(offsetted_ids)
819
+ if @media_index[media_sym]
820
+ @media_index[media_sym].concat(offsetted_ids)
715
821
  else
716
- @_media_index[media_sym] = offsetted_ids
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
- resolve_imports(new_imports, @options[:import], imported_urls: imported_urls, depth: depth)
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 @_media_index == other.instance_variable_get(:@_media_index)
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 media_index to match equality semantics.
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, @_media_index].hash # rubocop:disable Naming/MemoizedInstanceVariableName
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
- @_media_index = flattened.instance_variable_get(:@_media_index)
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(:@_media_index).each do |media_sym, rule_ids|
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 @_media_index[media_sym]
882
- @_media_index[media_sym].concat(offsetted_ids)
1006
+ if @media_index[media_sym]
1007
+ @media_index[media_sym].concat(offsetted_ids)
883
1008
  else
884
- @_media_index[media_sym] = offsetted_ids
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(:@_media_index).each_value do |ids|
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(:@_media_index).delete_if { |_media, ids| ids.empty? }
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
- media = import.media
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
- imported_sheet = Stylesheet.parse(imported_css, import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1))
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 media
1003
- imported_sheet.rules.each do |rule|
1004
- # Find rule's current media (if any) from imported sheet's media index
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
- # Combine media queries: "import_media and rule_media"
1017
- combined_media = if rule_media
1018
- # Combine: "media and rule_media"
1019
- :"#{media} and #{rule_media}"
1020
- else
1021
- media
1022
- end
1023
-
1024
- # Update media index
1025
- if @_media_index[combined_media]
1026
- @_media_index[combined_media] << rule.id
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
- @_media_index[combined_media] = [rule.id]
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(:@_media_index).each do |media_sym, rule_ids|
1042
- if @_media_index[media_sym]
1043
- @_media_index[media_sym].concat(rule_ids)
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
- @_media_index[media_sym] = rule_ids.dup
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| @_media_index[m]&.include?(rule_id) }
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. @_media_index)
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
- @_media_index.each do |media_type, rule_ids|
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