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.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -3
- data/BENCHMARKS.md +32 -32
- data/CHANGELOG.md +10 -0
- data/Gemfile +3 -0
- data/ext/cataract/cataract.c +219 -112
- data/ext/cataract/cataract.h +5 -1
- data/ext/cataract/css_parser.c +523 -33
- 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/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 +11 -0
- data/lib/cataract/pure/flatten.rb +127 -15
- data/lib/cataract/pure/parser.rb +637 -270
- data/lib/cataract/pure/serializer.rb +216 -115
- data/lib/cataract/pure.rb +6 -7
- data/lib/cataract/rule.rb +9 -5
- data/lib/cataract/stylesheet.rb +321 -99
- data/lib/cataract/stylesheet_scope.rb +10 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +4 -8
- data/lib/tasks/profile.rake +210 -0
- metadata +4 -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,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
|
-
@
|
|
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 =
|
|
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.
|
|
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
|
|
390
|
-
matching_rule_ids.concat(
|
|
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
|
-
#
|
|
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)
|
|
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.
|
|
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 =
|
|
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
|
|
455
|
-
matching_rule_ids.concat(
|
|
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
|
-
#
|
|
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)
|
|
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
|
-
@
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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,
|
|
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 @
|
|
714
|
-
@
|
|
801
|
+
if @media_index[media_sym]
|
|
802
|
+
@media_index[media_sym].concat(offsetted_ids)
|
|
715
803
|
else
|
|
716
|
-
@
|
|
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
|
-
|
|
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 @
|
|
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
|
|
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, @
|
|
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
|
-
@
|
|
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(:@
|
|
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 @
|
|
882
|
-
@
|
|
984
|
+
if @media_index[media_sym]
|
|
985
|
+
@media_index[media_sym].concat(offsetted_ids)
|
|
883
986
|
else
|
|
884
|
-
@
|
|
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(:@
|
|
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(:@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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(:@
|
|
1042
|
-
if @
|
|
1043
|
-
@
|
|
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
|
-
@
|
|
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|
|
|
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. @
|
|
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
|
-
|
|
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
|