cataract 0.2.3 → 0.2.4

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,15 +95,30 @@ 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
+ # Support :imports as alias for :import (backwards compatibility)
113
+ options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
114
+
49
115
  @options = {
50
116
  import: false,
51
117
  io_exceptions: true,
118
+ base_uri: nil,
119
+ base_dir: nil,
120
+ absolute_paths: false,
121
+ uri_resolver: nil,
52
122
  parser: {}
53
123
  }.merge(options)
54
124
 
@@ -58,9 +128,13 @@ module Cataract
58
128
  }.merge(@options[:parser] || {})
59
129
 
60
130
  @rules = [] # Flat array of Rule structs
61
- @_media_index = {} # Hash: Symbol => Array of rule IDs
131
+ @media_queries = [] # Array of MediaQuery objects
132
+ @_next_media_query_id = 0 # Counter for MediaQuery IDs
133
+ @media_index = {} # Hash: Symbol => Array of rule IDs (cached index, can be rebuilt from rules)
62
134
  @_selector_lists = {} # Hash: list_id => Array of rule IDs (for "h1, h2" grouping)
63
135
  @_next_selector_list_id = 0 # Counter for selector list IDs
136
+ @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print" grouping)
137
+ @_next_media_query_list_id = 0 # Counter for media query list IDs
64
138
  @charset = nil
65
139
  @imports = [] # Array of ImportStatement objects
66
140
  @_has_nesting = nil # Set by parser (nil or boolean)
@@ -78,10 +152,14 @@ module Cataract
78
152
  def initialize_copy(source)
79
153
  super
80
154
  @rules = source.instance_variable_get(:@rules).dup
155
+ @media_queries = source.instance_variable_get(:@media_queries).dup
156
+ @_next_media_query_id = source.instance_variable_get(:@_next_media_query_id)
157
+ @media_index = source.instance_variable_get(:@media_index).transform_values(&:dup)
81
158
  @imports = source.instance_variable_get(:@imports).dup
82
- @_media_index = source.instance_variable_get(:@_media_index).transform_values(&:dup)
83
159
  @_selector_lists = source.instance_variable_get(:@_selector_lists).transform_values(&:dup)
84
160
  @_next_selector_list_id = source.instance_variable_get(:@_next_selector_list_id)
161
+ @_media_query_lists = source.instance_variable_get(:@_media_query_lists).transform_values(&:dup)
162
+ @_next_media_query_list_id = source.instance_variable_get(:@_next_media_query_list_id)
85
163
  @parser_options = source.instance_variable_get(:@parser_options).dup
86
164
  clear_memoized_caches
87
165
  @_hash = nil # Clear cached hash
@@ -291,17 +369,10 @@ module Cataract
291
369
  # @return [Array<Rule>] Rules with no media query
292
370
  def base_rules
293
371
  # Rules not in any media_index entry
294
- media_rule_ids = @_media_index.values.flatten.uniq
372
+ media_rule_ids = media_index.values.flatten.uniq
295
373
  @rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
296
374
  end
297
375
 
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
376
  # Get all selectors
306
377
  #
307
378
  # @return [Array<String>] Array of all selectors
@@ -381,13 +452,14 @@ module Cataract
381
452
 
382
453
  # If :all is present, return everything (no filtering)
383
454
  if which_media_array.include?(:all)
384
- Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
455
+ Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
385
456
  else
386
457
  # Collect all rule IDs that match the requested media types
387
458
  matching_rule_ids = []
459
+ mi = media_index # Build media_index if needed
388
460
  which_media_array.each do |media_sym|
389
- if @_media_index[media_sym]
390
- matching_rule_ids.concat(@_media_index[media_sym])
461
+ if mi[media_sym]
462
+ matching_rule_ids.concat(mi[media_sym])
391
463
  end
392
464
  end
393
465
  matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
@@ -395,17 +467,8 @@ module Cataract
395
467
  # Build filtered rules array (keep original IDs, no recreation needed)
396
468
  filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
397
469
 
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)
470
+ # Serialize with filtered data
471
+ Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
409
472
  end
410
473
  end
411
474
  alias to_css to_s
@@ -438,21 +501,22 @@ module Cataract
438
501
 
439
502
  # If :all is present, return everything (no filtering)
440
503
  if which_media_array.include?(:all)
441
- Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false, @_selector_lists)
504
+ Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
442
505
  else
443
506
  # Collect all rule IDs that match the requested media types
444
507
  matching_rule_ids = []
508
+ mi = media_index # Build media_index if needed
445
509
 
446
510
  # Include rules not in any media query (they apply to all media)
447
- media_rule_ids = @_media_index.values.flatten.uniq
511
+ media_rule_ids = mi.values.flatten.uniq
448
512
  all_rule_ids = (0...@rules.length).to_a
449
513
  non_media_rule_ids = all_rule_ids - media_rule_ids
450
514
  matching_rule_ids.concat(non_media_rule_ids)
451
515
 
452
516
  # Include rules from requested media types
453
517
  which_media_array.each do |media_sym|
454
- if @_media_index[media_sym]
455
- matching_rule_ids.concat(@_media_index[media_sym])
518
+ if mi[media_sym]
519
+ matching_rule_ids.concat(mi[media_sym])
456
520
  end
457
521
  end
458
522
  matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
@@ -460,17 +524,8 @@ module Cataract
460
524
  # Build filtered rules array (keep original IDs, no recreation needed)
461
525
  filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
462
526
 
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)
527
+ # Serialize with filtered data
528
+ Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
474
529
  end
475
530
  end
476
531
 
@@ -495,7 +550,7 @@ module Cataract
495
550
  # @return [self] Returns self for method chaining
496
551
  def clear!
497
552
  @rules.clear
498
- @_media_index.clear
553
+ @media_index.clear
499
554
  @charset = nil
500
555
  clear_memoized_caches
501
556
  self
@@ -624,16 +679,14 @@ module Cataract
624
679
 
625
680
  # Check media type match if filter is specified
626
681
  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
682
+ rule_media_types = media_index.select { |_media, ids| ids.include?(rule_id) }.keys
630
683
 
631
684
  # If rule is not in any media query (base rule), skip unless :all is specified
632
- if individual_types.empty?
685
+ if rule_media_types.empty?
633
686
  next unless filter_media.include?(:all)
634
687
  else
635
688
  # Check if rule's media types intersect with filter
636
- next unless individual_types.intersect?(filter_media)
689
+ next unless rule_media_types.intersect?(filter_media)
637
690
  end
638
691
  end
639
692
 
@@ -645,7 +698,7 @@ module Cataract
645
698
  @rules.delete_at(rule_id)
646
699
 
647
700
  # Remove from media_index and update IDs for rules after this one
648
- @_media_index.each_value do |ids|
701
+ @media_index.each_value do |ids|
649
702
  ids.delete(rule_id)
650
703
  # Decrement IDs greater than removed ID
651
704
  ids.map! { |id| id > rule_id ? id - 1 : id }
@@ -653,7 +706,11 @@ module Cataract
653
706
  end
654
707
 
655
708
  # Clean up empty media_index entries
656
- @_media_index.delete_if { |_media, ids| ids.empty? }
709
+ @media_index.delete_if { |_media, ids| ids.empty? }
710
+
711
+ # Clean up unused MediaQuery objects (those not referenced by any rule)
712
+ used_mq_ids = @rules.filter_map { |r| r.media_query_id if r.is_a?(Rule) }.to_set
713
+ @media_queries.select! { |mq| used_mq_ids.include?(mq.id) }
657
714
 
658
715
  # Update rule IDs in remaining rules
659
716
  @rules.each_with_index { |rule, new_id| rule.id = new_id }
@@ -668,8 +725,11 @@ module Cataract
668
725
  # @param css [String] CSS string to add
669
726
  # @param fix_braces [Boolean] Automatically close missing braces
670
727
  # @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
728
+ # @param base_uri [String, nil] Override constructor's base_uri for this block
729
+ # @param base_dir [String, nil] Override constructor's base_dir for this block
730
+ # @param absolute_paths [Boolean, nil] Override constructor's absolute_paths for this block
671
731
  # @return [self] Returns self for method chaining
672
- def add_block(css, fix_braces: false, media_types: nil)
732
+ def add_block(css, fix_braces: false, media_types: nil, base_uri: nil, base_dir: nil, absolute_paths: nil)
673
733
  css += ' }' if fix_braces && !css.strip.end_with?('}')
674
734
 
675
735
  # Convenience wrapper: wrap in @media if media_types specified
@@ -678,14 +738,26 @@ module Cataract
678
738
  css = "@media #{media_list} { #{css} }"
679
739
  end
680
740
 
741
+ # Determine effective options (per-call overrides or constructor defaults)
742
+ effective_base_uri = base_uri || @options[:base_uri]
743
+ effective_base_dir = base_dir || @options[:base_dir]
744
+ effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
745
+
681
746
  # Get current rule ID offset
682
747
  offset = @_last_rule_id || 0
683
748
 
749
+ # Build parser options with URL conversion settings
750
+ parse_options = @parser_options.dup
751
+ if effective_absolute_paths && effective_base_uri
752
+ parse_options[:base_uri] = effective_base_uri
753
+ parse_options[:absolute_paths] = true
754
+ parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
755
+ end
756
+
684
757
  # Parse CSS first (this extracts @import statements into result[:imports])
685
- result = Cataract._parse_css(css, @parser_options)
758
+ result = Cataract._parse_css(css, parse_options)
686
759
 
687
760
  # Merge selector_lists with offsetted IDs
688
- # Must do this BEFORE updating rule IDs so we can update rule.selector_list_id
689
761
  list_id_offset = @_next_selector_list_id
690
762
  if result[:_selector_lists] && !result[:_selector_lists].empty?
691
763
  result[:_selector_lists].each do |list_id, rule_ids|
@@ -696,6 +768,18 @@ module Cataract
696
768
  @_next_selector_list_id = list_id_offset + result[:_selector_lists].size
697
769
  end
698
770
 
771
+ # Merge media_query_lists with offsetted IDs
772
+ media_query_id_offset = @_next_media_query_id
773
+ mq_list_id_offset = @_next_media_query_list_id
774
+ if result[:_media_query_lists] && !result[:_media_query_lists].empty?
775
+ result[:_media_query_lists].each do |list_id, mq_ids|
776
+ new_list_id = list_id + mq_list_id_offset
777
+ offsetted_mq_ids = mq_ids.map { |id| id + media_query_id_offset }
778
+ @_media_query_lists[new_list_id] = offsetted_mq_ids
779
+ end
780
+ @_next_media_query_list_id = mq_list_id_offset + result[:_media_query_lists].size
781
+ end
782
+
699
783
  # Merge rules with offsetted IDs
700
784
  new_rules = result[:rules]
701
785
  new_rules.each do |rule|
@@ -704,17 +788,30 @@ module Cataract
704
788
  if rule.is_a?(Rule) && rule.selector_list_id
705
789
  rule.selector_list_id += list_id_offset
706
790
  end
791
+ # Update media_query_id to point to offsetted MediaQuery
792
+ if rule.is_a?(Rule) && rule.media_query_id
793
+ rule.media_query_id += media_query_id_offset
794
+ end
707
795
  @rules << rule
708
796
  end
709
797
 
710
798
  # Merge media_index with offsetted IDs
711
799
  result[:_media_index].each do |media_sym, rule_ids|
712
800
  offsetted_ids = rule_ids.map { |id| id + offset }
713
- if @_media_index[media_sym]
714
- @_media_index[media_sym].concat(offsetted_ids)
801
+ if @media_index[media_sym]
802
+ @media_index[media_sym].concat(offsetted_ids)
715
803
  else
716
- @_media_index[media_sym] = offsetted_ids
804
+ @media_index[media_sym] = offsetted_ids
805
+ end
806
+ end
807
+
808
+ # Merge media_queries with offsetted IDs
809
+ if result[:media_queries]
810
+ result[:media_queries].each do |mq|
811
+ mq.id += media_query_id_offset
812
+ @media_queries << mq
717
813
  end
814
+ @_next_media_query_id += result[:media_queries].length
718
815
  end
719
816
 
720
817
  # Update last rule ID
@@ -738,7 +835,13 @@ module Cataract
738
835
  imported_urls = []
739
836
  depth = 0
740
837
  end
741
- resolve_imports(new_imports, @options[:import], imported_urls: imported_urls, depth: depth)
838
+
839
+ # Build import options with base_uri/base_dir for URL resolution
840
+ import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
841
+ import_opts[:base_uri] = effective_base_uri if effective_base_uri
842
+ import_opts[:base_path] = effective_base_dir if effective_base_dir
843
+
844
+ resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
742
845
  end
743
846
  end
744
847
 
@@ -799,7 +902,7 @@ module Cataract
799
902
  def ==(other)
800
903
  return false unless other.is_a?(Stylesheet)
801
904
  return false unless rules == other.rules
802
- return false unless @_media_index == other.instance_variable_get(:@_media_index)
905
+ return false unless @media_queries == other.instance_variable_get(:@media_queries)
803
906
 
804
907
  true
805
908
  end
@@ -807,11 +910,11 @@ module Cataract
807
910
 
808
911
  # Generate hash code for this stylesheet.
809
912
  #
810
- # Hash is based on rules and media_index to match equality semantics.
913
+ # Hash is based on rules and media_queries to match equality semantics.
811
914
  #
812
915
  # @return [Integer] hash code
813
916
  def hash
814
- @_hash ||= [self.class, rules, @_media_index].hash # rubocop:disable Naming/MemoizedInstanceVariableName
917
+ @_hash ||= [self.class, rules, @media_queries].hash # rubocop:disable Naming/MemoizedInstanceVariableName
815
918
  end
816
919
 
817
920
  # Flatten all rules in this stylesheet according to CSS cascade rules.
@@ -843,7 +946,7 @@ module Cataract
843
946
  def flatten!
844
947
  flattened = Cataract.flatten(self)
845
948
  @rules = flattened.instance_variable_get(:@rules)
846
- @_media_index = flattened.instance_variable_get(:@_media_index)
949
+ @media_index = flattened.instance_variable_get(:@media_index)
847
950
  @_has_nesting = flattened.instance_variable_get(:@_has_nesting)
848
951
  self
849
952
  end
@@ -876,12 +979,12 @@ module Cataract
876
979
  end
877
980
 
878
981
  # Merge media_index with offsetted IDs
879
- other.instance_variable_get(:@_media_index).each do |media_sym, rule_ids|
982
+ other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
880
983
  offsetted_ids = rule_ids.map { |id| id + offset }
881
- if @_media_index[media_sym]
882
- @_media_index[media_sym].concat(offsetted_ids)
984
+ if @media_index[media_sym]
985
+ @media_index[media_sym].concat(offsetted_ids)
883
986
  else
884
- @_media_index[media_sym] = offsetted_ids
987
+ @media_index[media_sym] = offsetted_ids
885
988
  end
886
989
  end
887
990
 
@@ -932,7 +1035,7 @@ module Cataract
932
1035
  result.rules.delete_at(idx)
933
1036
 
934
1037
  # Update media_index: remove this rule ID and decrement higher IDs
935
- result.instance_variable_get(:@_media_index).each_value do |ids|
1038
+ result.instance_variable_get(:@media_index).each_value do |ids|
936
1039
  ids.delete(idx)
937
1040
  ids.map! { |id| id > idx ? id - 1 : id }
938
1041
  end
@@ -942,7 +1045,43 @@ module Cataract
942
1045
  result.rules.each_with_index { |rule, new_id| rule.id = new_id }
943
1046
 
944
1047
  # Clean up empty media_index entries
945
- result.instance_variable_get(:@_media_index).delete_if { |_media, ids| ids.empty? }
1048
+ result.instance_variable_get(:@media_index).delete_if { |_media, ids| ids.empty? }
1049
+
1050
+ # Clean up unused MediaQuery objects and rebuild ID mapping
1051
+ used_mq_ids = Set.new
1052
+ result.rules.each do |rule|
1053
+ used_mq_ids << rule.media_query_id if rule.respond_to?(:media_query_id) && rule.media_query_id
1054
+ end
1055
+
1056
+ # Build old_id => new_id mapping
1057
+ # Keep MediaQuery objects that are used, maintaining their IDs
1058
+ old_to_new_mq_id = {}
1059
+ kept_mqs = []
1060
+ result.instance_variable_get(:@media_queries).each do |mq|
1061
+ next unless used_mq_ids.include?(mq.id)
1062
+
1063
+ old_to_new_mq_id[mq.id] = kept_mqs.size
1064
+ mq.id = kept_mqs.size
1065
+ kept_mqs << mq
1066
+ end
1067
+
1068
+ # Replace media_queries array with kept ones
1069
+ result.instance_variable_set(:@media_queries, kept_mqs)
1070
+
1071
+ # Update media_query_id references in rules
1072
+ result.rules.each do |rule|
1073
+ if rule.respond_to?(:media_query_id) && rule.media_query_id
1074
+ rule.media_query_id = old_to_new_mq_id[rule.media_query_id]
1075
+ end
1076
+ end
1077
+
1078
+ # Update media_query_lists with new IDs
1079
+ result.instance_variable_get(:@_media_query_lists).each_value do |mq_ids|
1080
+ mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact!
1081
+ end
1082
+
1083
+ # Clean up media_query_lists that are now empty
1084
+ result.instance_variable_get(:@_media_query_lists).delete_if { |_list_id, mq_ids| mq_ids.empty? }
946
1085
 
947
1086
  # Clear memoized cache
948
1087
  result.instance_variable_set(:@selectors, nil)
@@ -982,7 +1121,7 @@ module Cataract
982
1121
  next if import.resolved # Skip already resolved imports
983
1122
 
984
1123
  url = import.url
985
- media = import.media
1124
+ import_media_query_id = import.media_query_id
986
1125
 
987
1126
  # Validate URL
988
1127
  ImportResolver.validate_url(url, opts)
@@ -996,36 +1135,68 @@ module Cataract
996
1135
  # Parse imported CSS recursively
997
1136
  imported_urls_copy = imported_urls.dup
998
1137
  imported_urls_copy << url
999
- imported_sheet = Stylesheet.parse(imported_css, import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1))
1138
+
1139
+ # Determine the base URI for the imported file
1140
+ # This becomes the new base for resolving relative URLs in the imported CSS
1141
+ imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
1142
+
1143
+ # Build parse options for imported CSS
1144
+ parse_opts = {
1145
+ import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
1146
+ parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
1147
+ }
1148
+
1149
+ # If URL conversion is enabled (base_uri present), enable it for imported files too
1150
+ if opts[:base_uri]
1151
+ parse_opts[:absolute_paths] = true
1152
+ parse_opts[:base_uri] = imported_base_uri
1153
+ parse_opts[:uri_resolver] = opts[:uri_resolver]
1154
+ end
1155
+
1156
+ # Pass parent import's media query context to parser so nested imports can combine
1157
+ if import_media_query_id
1158
+ parent_mq = @media_queries[import_media_query_id]
1159
+ parse_opts[:parser][:parent_import_media_type] = parent_mq.type
1160
+ parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
1161
+ end
1162
+
1163
+ imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
1000
1164
 
1001
1165
  # 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
1166
+ if import_media_query_id
1167
+ # Get the import's MediaQuery object
1168
+ import_mq = @media_queries[import_media_query_id]
1015
1169
 
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
1170
+ imported_sheet.rules.each do |rule|
1171
+ next unless rule.is_a?(Rule)
1172
+
1173
+ if rule.media_query_id
1174
+ # Rule already has a media query - need to combine them
1175
+ # Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
1176
+ # Result: screen and (max-width: 768px)
1177
+ existing_mq = imported_sheet.media_queries[rule.media_query_id]
1178
+
1179
+ # Parse combined media query to extract type and conditions
1180
+ # The type is always the import's type (leftmost)
1181
+ combined_type = import_mq.type
1182
+ combined_conditions = if import_mq.conditions && existing_mq.conditions
1183
+ "#{import_mq.conditions} and #{existing_mq.conditions}"
1184
+ elsif import_mq.conditions
1185
+ "#{import_mq.conditions} and #{existing_mq.text}"
1186
+ elsif existing_mq.conditions
1187
+ existing_mq.conditions
1188
+ else
1189
+ existing_mq.text
1190
+ end
1191
+
1192
+ # Create combined MediaQuery
1193
+ combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
1194
+ @media_queries << combined_mq
1195
+ rule.media_query_id = @_next_media_query_id
1196
+ @_next_media_query_id += 1
1027
1197
  else
1028
- @_media_index[combined_media] = [rule.id]
1198
+ # Rule has no media query - just assign the import's media query
1199
+ rule.media_query_id = import_media_query_id
1029
1200
  end
1030
1201
  end
1031
1202
  end
@@ -1033,17 +1204,41 @@ module Cataract
1033
1204
  # Merge imported rules into this stylesheet
1034
1205
  # Insert at current position (before any remaining local rules)
1035
1206
  insert_position = import.id
1207
+
1208
+ # Insert rules without modifying IDs (will renumber everything after all imports resolved)
1036
1209
  imported_sheet.rules.each_with_index do |rule, idx|
1037
1210
  @rules.insert(insert_position + idx, rule)
1038
1211
  end
1039
1212
 
1040
1213
  # 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)
1214
+ imported_sheet.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
1215
+ if @media_index[media_sym]
1216
+ @media_index[media_sym].concat(rule_ids)
1044
1217
  else
1045
- @_media_index[media_sym] = rule_ids.dup
1218
+ @media_index[media_sym] = rule_ids.dup
1219
+ end
1220
+ end
1221
+
1222
+ # Merge selector_lists with offsetted IDs
1223
+ list_id_offset = @_next_selector_list_id
1224
+ imported_selector_lists = imported_sheet.instance_variable_get(:@_selector_lists)
1225
+ if imported_selector_lists && !imported_selector_lists.empty?
1226
+ imported_selector_lists.each do |list_id, rule_ids|
1227
+ new_list_id = list_id + list_id_offset
1228
+ @_selector_lists[new_list_id] = rule_ids.dup
1229
+ end
1230
+ @_next_selector_list_id = list_id_offset + imported_selector_lists.size
1231
+ end
1232
+
1233
+ # Merge media_query_lists with offsetted IDs
1234
+ mq_list_id_offset = @_next_media_query_list_id
1235
+ imported_mq_lists = imported_sheet.instance_variable_get(:@_media_query_lists)
1236
+ if imported_mq_lists && !imported_mq_lists.empty?
1237
+ imported_mq_lists.each do |list_id, mq_ids|
1238
+ new_list_id = list_id + mq_list_id_offset
1239
+ @_media_query_lists[new_list_id] = mq_ids.dup
1046
1240
  end
1241
+ @_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
1047
1242
  end
1048
1243
 
1049
1244
  # Merge charset (first one wins per CSS spec)
@@ -1052,6 +1247,33 @@ module Cataract
1052
1247
  # Mark as resolved
1053
1248
  import.resolved = true
1054
1249
  end
1250
+
1251
+ # Renumber all rule IDs to be sequential in document order
1252
+ # This is O(n) and very fast (~1ms for 30k rules)
1253
+ # Only needed if we actually resolved imports
1254
+ return unless imports.length > 0
1255
+
1256
+ # Single-pass renumbering: build old->new mapping while renumbering
1257
+ old_to_new_id = {}
1258
+ @rules.each_with_index do |rule, new_idx|
1259
+ if rule.is_a?(Rule) || rule.is_a?(ImportStatement)
1260
+ old_to_new_id[rule.id] = new_idx
1261
+ rule.id = new_idx
1262
+ end
1263
+ end
1264
+
1265
+ # Update rule IDs in selector_lists (only if we have any)
1266
+ unless @_selector_lists.empty?
1267
+ @_selector_lists.each do |list_id, rule_ids|
1268
+ @_selector_lists[list_id] = rule_ids.map { |old_id| old_to_new_id[old_id] }
1269
+ end
1270
+ end
1271
+
1272
+ # Update @_last_rule_id to reflect final count
1273
+ @_last_rule_id = @rules.length
1274
+
1275
+ # Clear media_index so it gets rebuilt lazily when accessed
1276
+ @media_index = {}
1055
1277
  end
1056
1278
 
1057
1279
  # Check if a rule matches any of the requested media queries
@@ -1060,7 +1282,7 @@ module Cataract
1060
1282
  # @param query_media [Array<Symbol>] Media types to match
1061
1283
  # @return [Boolean] true if rule appears in any of the requested media index entries
1062
1284
  def rule_matches_media?(rule_id, query_media)
1063
- query_media.any? { |m| @_media_index[m]&.include?(rule_id) }
1285
+ query_media.any? { |m| media_index[m]&.include?(rule_id) }
1064
1286
  end
1065
1287
 
1066
1288
  # Check if a rule matches the specificity filter
@@ -1090,7 +1312,7 @@ module Cataract
1090
1312
  # - @selectors: Memoized list of all selectors
1091
1313
  # - @_custom_properties: Memoized custom properties organized by media context
1092
1314
  #
1093
- # Should not add ivars here that don't rebuild themselves (i.e. @_media_index)
1315
+ # Should not add ivars here that don't rebuild themselves (i.e. @media_index)
1094
1316
  def clear_memoized_caches
1095
1317
  @selectors = nil
1096
1318
  @_custom_properties = nil
@@ -1104,7 +1326,7 @@ module Cataract
1104
1326
 
1105
1327
  # Build reverse lookup: rule_id => media_type
1106
1328
  rule_id_to_media = {}
1107
- @_media_index.each do |media_type, rule_ids|
1329
+ media_index.each do |media_type, rule_ids|
1108
1330
  rule_ids.each do |rule_id|
1109
1331
  rule_id_to_media[rule_id] = media_type
1110
1332
  end