cataract 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +6 -6
- data/.gitignore +17 -3
- data/.rubocop.yml +1 -0
- data/BENCHMARKS.md +24 -6
- data/CHANGELOG.md +29 -1
- data/Gemfile +2 -2
- data/examples/css_analyzer/analyzer.rb +9 -3
- data/examples/css_analyzer/analyzers/base.rb +1 -1
- data/ext/cataract/cataract.c +251 -458
- data/ext/cataract/cataract.h +127 -16
- data/ext/cataract/css_parser.c +1029 -700
- data/ext/cataract/extconf.rb +1 -2
- data/ext/cataract/flatten.c +279 -991
- data/ext/cataract/shorthand_expander.c +80 -102
- data/ext/cataract_color/color_conversion.c +1 -1
- data/lib/cataract/declarations.rb +13 -7
- data/lib/cataract/error.rb +49 -0
- data/lib/cataract/import_resolver.rb +36 -16
- data/lib/cataract/pure/byte_constants.rb +4 -1
- data/lib/cataract/pure/flatten.rb +143 -145
- data/lib/cataract/pure/parser.rb +623 -388
- data/lib/cataract/pure/serializer.rb +109 -104
- data/lib/cataract/pure/specificity.rb +112 -156
- data/lib/cataract/pure.rb +1 -6
- data/lib/cataract/stylesheet.rb +480 -410
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +1 -0
- metadata +3 -13
- data/ext/cataract/import_scanner.c +0 -174
- data/ext/cataract_old/cataract.c +0 -393
- data/ext/cataract_old/cataract.h +0 -250
- data/ext/cataract_old/css_parser.c +0 -933
- data/ext/cataract_old/extconf.rb +0 -67
- data/ext/cataract_old/import_scanner.c +0 -174
- data/ext/cataract_old/merge.c +0 -776
- data/ext/cataract_old/shorthand_expander.c +0 -902
- data/ext/cataract_old/specificity.c +0 -213
- data/ext/cataract_old/stylesheet.c +0 -290
- data/ext/cataract_old/value_splitter.c +0 -116
data/lib/cataract/stylesheet.rb
CHANGED
|
@@ -109,6 +109,9 @@ module Cataract
|
|
|
109
109
|
# @option options [Hash] :parser ({}) Parser configuration options
|
|
110
110
|
# - :selector_lists [Boolean] (true) Track selector lists for W3C-compliant serialization
|
|
111
111
|
def initialize(options = {})
|
|
112
|
+
# Type validation
|
|
113
|
+
raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)
|
|
114
|
+
|
|
112
115
|
# Support :imports as alias for :import (backwards compatibility)
|
|
113
116
|
options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)
|
|
114
117
|
|
|
@@ -119,12 +122,27 @@ module Cataract
|
|
|
119
122
|
base_dir: nil,
|
|
120
123
|
absolute_paths: false,
|
|
121
124
|
uri_resolver: nil,
|
|
122
|
-
parser: {}
|
|
125
|
+
parser: {},
|
|
126
|
+
raise_parse_errors: false
|
|
123
127
|
}.merge(options)
|
|
124
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
|
+
|
|
125
142
|
# Parser options with defaults (stored for passing to parser)
|
|
126
143
|
@parser_options = {
|
|
127
|
-
selector_lists: true
|
|
144
|
+
selector_lists: true,
|
|
145
|
+
raise_parse_errors: @options[:raise_parse_errors]
|
|
128
146
|
}.merge(@options[:parser] || {})
|
|
129
147
|
|
|
130
148
|
@rules = [] # Flat array of Rule structs
|
|
@@ -151,16 +169,17 @@ module Cataract
|
|
|
151
169
|
# @param source [Stylesheet] Source stylesheet being copied
|
|
152
170
|
def initialize_copy(source)
|
|
153
171
|
super
|
|
154
|
-
@
|
|
155
|
-
@
|
|
156
|
-
@
|
|
157
|
-
@
|
|
158
|
-
@
|
|
159
|
-
@
|
|
160
|
-
@
|
|
161
|
-
@
|
|
162
|
-
@
|
|
163
|
-
@
|
|
172
|
+
@options = source.options.dup
|
|
173
|
+
@rules = source.rules.dup
|
|
174
|
+
@media_queries = source.media_queries.dup
|
|
175
|
+
@_next_media_query_id = source.next_media_query_id
|
|
176
|
+
@media_index = source.media_index_cache.transform_values(&:dup)
|
|
177
|
+
@imports = source.imports.dup
|
|
178
|
+
@_selector_lists = source.selector_lists.transform_values(&:dup)
|
|
179
|
+
@_next_selector_list_id = source.next_selector_list_id
|
|
180
|
+
@_media_query_lists = source.media_query_lists.transform_values(&:dup)
|
|
181
|
+
@_next_media_query_list_id = source.next_media_query_list_id
|
|
182
|
+
@parser_options = source.parser_options.dup
|
|
164
183
|
clear_memoized_caches
|
|
165
184
|
@_hash = nil # Clear cached hash
|
|
166
185
|
end
|
|
@@ -180,11 +199,12 @@ module Cataract
|
|
|
180
199
|
#
|
|
181
200
|
# @param filename [String] Path to the CSS file
|
|
182
201
|
# @param base_dir [String] Base directory for resolving the filename (default: '.')
|
|
183
|
-
# @param options [Hash] Options passed to Stylesheet.new
|
|
202
|
+
# @param options [Hash] Options passed to Stylesheet.new, and to load_file
|
|
203
|
+
# (e.g. dangerous_path_prefixes: [] to load a path load_file blocks by default)
|
|
184
204
|
# @return [Stylesheet] A new Stylesheet containing the parsed CSS
|
|
185
205
|
def self.load_file(filename, base_dir = '.', **options)
|
|
186
206
|
sheet = new(options)
|
|
187
|
-
sheet.load_file(filename, base_dir)
|
|
207
|
+
sheet.load_file(filename, base_dir, options)
|
|
188
208
|
sheet
|
|
189
209
|
end
|
|
190
210
|
|
|
@@ -427,49 +447,26 @@ module Cataract
|
|
|
427
447
|
#
|
|
428
448
|
# @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
|
|
429
449
|
# - :all - Output all rules including base rules and all media queries
|
|
430
|
-
# - :screen, :print, etc. - Output
|
|
431
|
-
# - [:screen, :print] - Output rules from multiple media queries
|
|
450
|
+
# - :screen, :print, etc. - Output base rules plus rules from the specified media query
|
|
451
|
+
# - [:screen, :print] - Output base rules plus rules from multiple media queries
|
|
432
452
|
#
|
|
433
453
|
# Important: When filtering to specific media types, base rules (rules not
|
|
434
|
-
# inside any @media block) are
|
|
435
|
-
#
|
|
454
|
+
# inside any @media block) are always included, since they apply regardless
|
|
455
|
+
# of media context. Only rules from OTHER @media queries are excluded.
|
|
436
456
|
# @return [String] CSS string
|
|
437
457
|
#
|
|
438
458
|
# @example Get all CSS
|
|
439
459
|
# sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
440
460
|
# sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
441
461
|
#
|
|
442
|
-
# @example Filter to specific media type (
|
|
443
|
-
# sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
|
|
444
|
-
# # Note: base rules like "body { color: black; }"
|
|
462
|
+
# @example Filter to specific media type (base rules still included)
|
|
463
|
+
# sheet.to_s(media: :print) # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
464
|
+
# # Note: base rules like "body { color: black; }" apply during print too, so they're kept
|
|
445
465
|
#
|
|
446
466
|
# @example Filter to multiple media types
|
|
447
467
|
# sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
|
|
448
468
|
def to_s(media: :all)
|
|
449
|
-
|
|
450
|
-
# Normalize to array for consistent filtering
|
|
451
|
-
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
452
|
-
|
|
453
|
-
# If :all is present, return everything (no filtering)
|
|
454
|
-
if which_media_array.include?(:all)
|
|
455
|
-
Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
456
|
-
else
|
|
457
|
-
# Collect all rule IDs that match the requested media types
|
|
458
|
-
matching_rule_ids = []
|
|
459
|
-
mi = media_index # Build media_index if needed
|
|
460
|
-
which_media_array.each do |media_sym|
|
|
461
|
-
if mi[media_sym]
|
|
462
|
-
matching_rule_ids.concat(mi[media_sym])
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
466
|
-
|
|
467
|
-
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
468
|
-
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
469
|
-
|
|
470
|
-
# Serialize with filtered data
|
|
471
|
-
Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
472
|
-
end
|
|
469
|
+
Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
473
470
|
end
|
|
474
471
|
alias to_css to_s
|
|
475
472
|
|
|
@@ -479,10 +476,10 @@ module Cataract
|
|
|
479
476
|
# Rules are formatted with each declaration on its own line, and media queries
|
|
480
477
|
# are properly indented. Optionally filters output to specific media queries.
|
|
481
478
|
#
|
|
482
|
-
# @param
|
|
479
|
+
# @param media [Symbol, Array<Symbol>] Optional media filter (default: :all)
|
|
483
480
|
# - :all - Output all rules including base rules and all media queries
|
|
484
|
-
# - :screen, :print, etc. - Output
|
|
485
|
-
# - [:screen, :print] - Output rules from multiple media queries
|
|
481
|
+
# - :screen, :print, etc. - Output base rules plus rules from the specified media query
|
|
482
|
+
# - [:screen, :print] - Output base rules plus rules from multiple media queries
|
|
486
483
|
#
|
|
487
484
|
# @return [String] Formatted CSS string
|
|
488
485
|
#
|
|
@@ -490,43 +487,12 @@ module Cataract
|
|
|
490
487
|
# sheet.to_formatted_s
|
|
491
488
|
# # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
|
|
492
489
|
#
|
|
493
|
-
# @example Filter to specific media type
|
|
494
|
-
# sheet.to_formatted_s(:print)
|
|
490
|
+
# @example Filter to specific media type (base rules still included)
|
|
491
|
+
# sheet.to_formatted_s(media: :print)
|
|
495
492
|
#
|
|
496
493
|
# @see #to_s For compact single-line output
|
|
497
494
|
def to_formatted_s(media: :all)
|
|
498
|
-
|
|
499
|
-
# Normalize to array for consistent filtering
|
|
500
|
-
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
501
|
-
|
|
502
|
-
# If :all is present, return everything (no filtering)
|
|
503
|
-
if which_media_array.include?(:all)
|
|
504
|
-
Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
505
|
-
else
|
|
506
|
-
# Collect all rule IDs that match the requested media types
|
|
507
|
-
matching_rule_ids = []
|
|
508
|
-
mi = media_index # Build media_index if needed
|
|
509
|
-
|
|
510
|
-
# Include rules not in any media query (they apply to all media)
|
|
511
|
-
media_rule_ids = mi.values.flatten.uniq
|
|
512
|
-
all_rule_ids = (0...@rules.length).to_a
|
|
513
|
-
non_media_rule_ids = all_rule_ids - media_rule_ids
|
|
514
|
-
matching_rule_ids.concat(non_media_rule_ids)
|
|
515
|
-
|
|
516
|
-
# Include rules from requested media types
|
|
517
|
-
which_media_array.each do |media_sym|
|
|
518
|
-
if mi[media_sym]
|
|
519
|
-
matching_rule_ids.concat(mi[media_sym])
|
|
520
|
-
end
|
|
521
|
-
end
|
|
522
|
-
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
523
|
-
|
|
524
|
-
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
525
|
-
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
526
|
-
|
|
527
|
-
# Serialize with filtered data
|
|
528
|
-
Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
529
|
-
end
|
|
495
|
+
Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
530
496
|
end
|
|
531
497
|
|
|
532
498
|
# Get number of rules
|
|
@@ -560,14 +526,15 @@ module Cataract
|
|
|
560
526
|
#
|
|
561
527
|
# @param filename [String] Path to the CSS file
|
|
562
528
|
# @param base_dir [String] Base directory for resolving the filename (default: '.')
|
|
529
|
+
# @param options [Hash] Passed through to load_uri (e.g. dangerous_path_prefixes: [])
|
|
563
530
|
# @return [self] Returns self for method chaining
|
|
564
|
-
def load_file(filename, base_dir = '.',
|
|
531
|
+
def load_file(filename, base_dir = '.', options = {})
|
|
565
532
|
# Normalize file path and convert to file:// URI
|
|
566
533
|
file_path = File.expand_path(filename, base_dir)
|
|
567
534
|
file_uri = "file://#{file_path}"
|
|
568
535
|
|
|
569
536
|
# Delegate to load_uri which handles imports and base_path
|
|
570
|
-
load_uri(file_uri)
|
|
537
|
+
load_uri(file_uri, options)
|
|
571
538
|
end
|
|
572
539
|
|
|
573
540
|
# Load CSS from a URI and add to this stylesheet.
|
|
@@ -577,20 +544,25 @@ module Cataract
|
|
|
577
544
|
# @return [self] Returns self for method chaining
|
|
578
545
|
def load_uri(uri, options = {})
|
|
579
546
|
require 'uri'
|
|
580
|
-
require 'net/http'
|
|
581
547
|
|
|
582
548
|
uri_obj = URI(uri)
|
|
549
|
+
# Reuse the same validation and fetch collaborator @import resolution
|
|
550
|
+
# uses (ImportResolver, via DefaultFetcher/open-uri) instead of a
|
|
551
|
+
# second, separate Net::HTTP implementation with no validation at all -
|
|
552
|
+
# that duplicate implementation also didn't follow redirects, unlike
|
|
553
|
+
# this one. LOAD_DEFAULTS (rather than SAFE_DEFAULTS) reflects that the
|
|
554
|
+
# caller chose this exact URI themselves, so http/any-extension are
|
|
555
|
+
# allowed by default; dangerous_path_prefixes still applies unless the
|
|
556
|
+
# caller overrides it (e.g. dangerous_path_prefixes: []).
|
|
557
|
+
opts = ImportResolver.normalize_options(options, defaults: ImportResolver::LOAD_DEFAULTS)
|
|
558
|
+
fetcher = opts[:fetcher] || @options[:import_fetcher] || ImportResolver::DefaultFetcher.new
|
|
583
559
|
css_content = nil
|
|
584
560
|
file_path = nil
|
|
585
561
|
|
|
586
562
|
case uri_obj.scheme
|
|
587
563
|
when 'http', 'https'
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
css_content = response.body
|
|
564
|
+
ImportResolver.validate_url(uri, opts)
|
|
565
|
+
css_content = fetcher.call(uri, opts)
|
|
594
566
|
when 'file', nil
|
|
595
567
|
# file:// URI or relative path
|
|
596
568
|
path = uri_obj.scheme == 'file' ? uri_obj.path : uri
|
|
@@ -607,7 +579,9 @@ module Cataract
|
|
|
607
579
|
@options[:import] = @options[:import].merge(base_path: file_dir)
|
|
608
580
|
end
|
|
609
581
|
|
|
610
|
-
|
|
582
|
+
file_uri = "file://#{file_path}"
|
|
583
|
+
ImportResolver.validate_url(file_uri, opts)
|
|
584
|
+
css_content = fetcher.call(file_uri, opts)
|
|
611
585
|
else
|
|
612
586
|
raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
|
|
613
587
|
end
|
|
@@ -708,9 +682,7 @@ module Cataract
|
|
|
708
682
|
# Clean up empty media_index entries
|
|
709
683
|
@media_index.delete_if { |_media, ids| ids.empty? }
|
|
710
684
|
|
|
711
|
-
|
|
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) }
|
|
685
|
+
compact_media_queries!
|
|
714
686
|
|
|
715
687
|
# Update rule IDs in remaining rules
|
|
716
688
|
@rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
@@ -743,115 +715,12 @@ module Cataract
|
|
|
743
715
|
effective_base_dir = base_dir || @options[:base_dir]
|
|
744
716
|
effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
|
|
745
717
|
|
|
746
|
-
|
|
747
|
-
offset = @_last_rule_id || 0
|
|
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
|
|
718
|
+
parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
|
|
756
719
|
|
|
757
720
|
# Parse CSS first (this extracts @import statements into result[:imports])
|
|
758
721
|
result = Cataract._parse_css(css, parse_options)
|
|
759
722
|
|
|
760
|
-
|
|
761
|
-
list_id_offset = @_next_selector_list_id
|
|
762
|
-
if result[:_selector_lists] && !result[:_selector_lists].empty?
|
|
763
|
-
result[:_selector_lists].each do |list_id, rule_ids|
|
|
764
|
-
new_list_id = list_id + list_id_offset
|
|
765
|
-
offsetted_rule_ids = rule_ids.map { |id| id + offset }
|
|
766
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
767
|
-
end
|
|
768
|
-
@_next_selector_list_id = list_id_offset + result[:_selector_lists].size
|
|
769
|
-
end
|
|
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
|
-
|
|
783
|
-
# Merge rules with offsetted IDs
|
|
784
|
-
new_rules = result[:rules]
|
|
785
|
-
new_rules.each do |rule|
|
|
786
|
-
rule.id += offset
|
|
787
|
-
# Update selector_list_id to point to offsetted list (only for Rule, not AtRule)
|
|
788
|
-
if rule.is_a?(Rule) && rule.selector_list_id
|
|
789
|
-
rule.selector_list_id += list_id_offset
|
|
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
|
|
795
|
-
@rules << rule
|
|
796
|
-
end
|
|
797
|
-
|
|
798
|
-
# Merge media_index with offsetted IDs
|
|
799
|
-
result[:_media_index].each do |media_sym, rule_ids|
|
|
800
|
-
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
801
|
-
if @media_index[media_sym]
|
|
802
|
-
@media_index[media_sym].concat(offsetted_ids)
|
|
803
|
-
else
|
|
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
|
|
813
|
-
end
|
|
814
|
-
@_next_media_query_id += result[:media_queries].length
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
# Update last rule ID
|
|
818
|
-
@_last_rule_id = offset + new_rules.length
|
|
819
|
-
|
|
820
|
-
# Merge imports with offsetted IDs
|
|
821
|
-
if result[:imports]
|
|
822
|
-
new_imports = result[:imports]
|
|
823
|
-
new_imports.each do |import|
|
|
824
|
-
import.id += offset
|
|
825
|
-
@imports << import
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
# Resolve imports if configured
|
|
829
|
-
if @options[:import]
|
|
830
|
-
# Extract imported_urls and depth from options
|
|
831
|
-
if @options[:import].is_a?(Hash)
|
|
832
|
-
imported_urls = @options[:import][:imported_urls] || []
|
|
833
|
-
depth = @options[:import][:depth] || 0
|
|
834
|
-
else
|
|
835
|
-
imported_urls = []
|
|
836
|
-
depth = 0
|
|
837
|
-
end
|
|
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)
|
|
845
|
-
end
|
|
846
|
-
end
|
|
847
|
-
|
|
848
|
-
# Set charset if not already set
|
|
849
|
-
@charset ||= result[:charset]
|
|
850
|
-
|
|
851
|
-
# Track if we have any nesting (for serialization optimization)
|
|
852
|
-
@_has_nesting = result[:_has_nesting]
|
|
853
|
-
|
|
854
|
-
clear_memoized_caches
|
|
723
|
+
merge_parsed_block!(result, effective_base_uri, effective_base_dir)
|
|
855
724
|
|
|
856
725
|
self
|
|
857
726
|
end
|
|
@@ -902,7 +771,7 @@ module Cataract
|
|
|
902
771
|
def ==(other)
|
|
903
772
|
return false unless other.is_a?(Stylesheet)
|
|
904
773
|
return false unless rules == other.rules
|
|
905
|
-
return false unless @media_queries == other.
|
|
774
|
+
return false unless @media_queries == other.media_queries
|
|
906
775
|
|
|
907
776
|
true
|
|
908
777
|
end
|
|
@@ -945,9 +814,9 @@ module Cataract
|
|
|
945
814
|
# @return [self] Returns self for method chaining
|
|
946
815
|
def flatten!
|
|
947
816
|
flattened = Cataract.flatten(self)
|
|
948
|
-
@rules = flattened.
|
|
949
|
-
@media_index = flattened.
|
|
950
|
-
@_has_nesting = flattened.
|
|
817
|
+
@rules = flattened.rules
|
|
818
|
+
@media_index = flattened.media_index_cache
|
|
819
|
+
@_has_nesting = flattened.has_nesting
|
|
951
820
|
self
|
|
952
821
|
end
|
|
953
822
|
alias cascade! flatten!
|
|
@@ -968,29 +837,23 @@ module Cataract
|
|
|
968
837
|
def concat(other)
|
|
969
838
|
raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
|
|
970
839
|
|
|
971
|
-
# Get the current offset for rule IDs
|
|
840
|
+
# Get the current offset for rule/media-query/selector-list IDs
|
|
972
841
|
offset = @rules.length
|
|
842
|
+
list_id_offset = merge_selector_lists!(other.selector_lists, rule_id_offset: offset)
|
|
843
|
+
mq_id_offset = merge_media_queries!(other.media_queries)
|
|
844
|
+
merge_media_query_lists!(other.media_query_lists, mq_id_offset: mq_id_offset)
|
|
973
845
|
|
|
974
|
-
# Add rules with updated IDs
|
|
846
|
+
# Add rules with updated IDs and cross-references
|
|
975
847
|
other.rules.each do |rule|
|
|
976
848
|
new_rule = rule.dup
|
|
977
|
-
new_rule
|
|
849
|
+
rebase_rule!(new_rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
|
|
978
850
|
@rules << new_rule
|
|
979
851
|
end
|
|
980
852
|
|
|
981
|
-
|
|
982
|
-
other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
|
|
983
|
-
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
984
|
-
if @media_index[media_sym]
|
|
985
|
-
@media_index[media_sym].concat(offsetted_ids)
|
|
986
|
-
else
|
|
987
|
-
@media_index[media_sym] = offsetted_ids
|
|
988
|
-
end
|
|
989
|
-
end
|
|
853
|
+
merge_media_index!(other.media_index_cache, rule_id_offset: offset)
|
|
990
854
|
|
|
991
855
|
# Update nesting flag if other has nesting
|
|
992
|
-
|
|
993
|
-
@_has_nesting = true if other_has_nesting
|
|
856
|
+
@_has_nesting = true if other.has_nesting
|
|
994
857
|
|
|
995
858
|
clear_memoized_caches
|
|
996
859
|
|
|
@@ -1035,7 +898,7 @@ module Cataract
|
|
|
1035
898
|
result.rules.delete_at(idx)
|
|
1036
899
|
|
|
1037
900
|
# Update media_index: remove this rule ID and decrement higher IDs
|
|
1038
|
-
result.
|
|
901
|
+
result.media_index_cache.each_value do |ids|
|
|
1039
902
|
ids.delete(idx)
|
|
1040
903
|
ids.map! { |id| id > idx ? id - 1 : id }
|
|
1041
904
|
end
|
|
@@ -1045,58 +908,301 @@ module Cataract
|
|
|
1045
908
|
result.rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
1046
909
|
|
|
1047
910
|
# Clean up empty media_index entries
|
|
1048
|
-
result.
|
|
911
|
+
result.media_index_cache.delete_if { |_media, ids| ids.empty? }
|
|
912
|
+
|
|
913
|
+
result.send(:compact_media_queries!)
|
|
914
|
+
|
|
915
|
+
# Clear memoized cache
|
|
916
|
+
result.send(:clear_memoized_caches)
|
|
917
|
+
result._hash = nil
|
|
918
|
+
|
|
919
|
+
result
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
protected
|
|
923
|
+
|
|
924
|
+
# Internal accessors for another Stylesheet instance's private state.
|
|
925
|
+
# Protected (not public) so this stays reachable from sibling instances
|
|
926
|
+
# within this class (dup/clone, concat, resolve_imports, -) without
|
|
927
|
+
# becoming part of the public API - and not plain attr_readers, since
|
|
928
|
+
# several ivar names would collide with unrelated public methods
|
|
929
|
+
# (notably #media_index, which lazily rebuilds rather than exposing the
|
|
930
|
+
# raw cache these need).
|
|
931
|
+
|
|
932
|
+
attr_reader :options, :parser_options
|
|
933
|
+
|
|
934
|
+
def next_media_query_id
|
|
935
|
+
@_next_media_query_id
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
def next_selector_list_id
|
|
939
|
+
@_next_selector_list_id
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def next_media_query_list_id
|
|
943
|
+
@_next_media_query_list_id
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
# @return [Hash{Symbol => Array<Integer>}] the raw cached media index,
|
|
947
|
+
# without triggering the public #media_index reader's lazy rebuild
|
|
948
|
+
def media_index_cache
|
|
949
|
+
@media_index
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def has_nesting
|
|
953
|
+
@_has_nesting
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def selector_lists
|
|
957
|
+
@_selector_lists
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def media_query_lists
|
|
961
|
+
@_media_query_lists
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
attr_writer :_hash
|
|
965
|
+
|
|
966
|
+
private
|
|
967
|
+
|
|
968
|
+
# Offset and append MediaQuery objects into @media_queries, without
|
|
969
|
+
# mutating the source objects (a caller may still hold its own
|
|
970
|
+
# references to them, e.g. concat's `other`). Returns the id offset
|
|
971
|
+
# applied, so callers can rebase any rule/import media_query_id that
|
|
972
|
+
# pointed into the source collection.
|
|
973
|
+
#
|
|
974
|
+
# @param source_media_queries [Array<MediaQuery>, nil]
|
|
975
|
+
# @return [Integer] id offset applied to each merged MediaQuery
|
|
976
|
+
def merge_media_queries!(source_media_queries)
|
|
977
|
+
mq_id_offset = @_next_media_query_id
|
|
978
|
+
return mq_id_offset if source_media_queries.nil? || source_media_queries.empty?
|
|
979
|
+
|
|
980
|
+
source_media_queries.each do |mq|
|
|
981
|
+
new_mq = mq.dup
|
|
982
|
+
new_mq.id += mq_id_offset
|
|
983
|
+
@media_queries << new_mq
|
|
984
|
+
end
|
|
985
|
+
@_next_media_query_id += source_media_queries.size
|
|
986
|
+
|
|
987
|
+
mq_id_offset
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
# Offset and merge a selector_lists hash (list_id => [rule_ids]) into
|
|
991
|
+
# @_selector_lists. List ids are always rebased onto the next available
|
|
992
|
+
# id; rule ids are rebased by rule_id_offset (0 when the caller defers
|
|
993
|
+
# rule-id fixups to a later bulk renumber).
|
|
994
|
+
#
|
|
995
|
+
# @param source_lists [Hash{Integer => Array<Integer>}, nil]
|
|
996
|
+
# @param rule_id_offset [Integer]
|
|
997
|
+
# @return [Integer] id offset applied to the source's list ids
|
|
998
|
+
def merge_selector_lists!(source_lists, rule_id_offset: 0)
|
|
999
|
+
list_id_offset = @_next_selector_list_id
|
|
1000
|
+
return list_id_offset if source_lists.nil? || source_lists.empty?
|
|
1001
|
+
|
|
1002
|
+
source_lists.each do |list_id, rule_ids|
|
|
1003
|
+
@_selector_lists[list_id + list_id_offset] = rule_ids.map { |id| id + rule_id_offset }
|
|
1004
|
+
end
|
|
1005
|
+
@_next_selector_list_id = list_id_offset + source_lists.size
|
|
1006
|
+
|
|
1007
|
+
list_id_offset
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Same shape as merge_selector_lists!, for @_media_query_lists
|
|
1011
|
+
# (list_id => [media_query_ids]).
|
|
1012
|
+
#
|
|
1013
|
+
# @param source_lists [Hash{Integer => Array<Integer>}, nil]
|
|
1014
|
+
# @param mq_id_offset [Integer]
|
|
1015
|
+
# @return [Integer] id offset applied to the source's list ids
|
|
1016
|
+
def merge_media_query_lists!(source_lists, mq_id_offset: 0)
|
|
1017
|
+
list_id_offset = @_next_media_query_list_id
|
|
1018
|
+
return list_id_offset if source_lists.nil? || source_lists.empty?
|
|
1019
|
+
|
|
1020
|
+
source_lists.each do |list_id, mq_ids|
|
|
1021
|
+
@_media_query_lists[list_id + list_id_offset] = mq_ids.map { |id| id + mq_id_offset }
|
|
1022
|
+
end
|
|
1023
|
+
@_next_media_query_list_id = list_id_offset + source_lists.size
|
|
1024
|
+
|
|
1025
|
+
list_id_offset
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
# Merge a media_index hash (media_sym => [rule_ids]) into @media_index,
|
|
1029
|
+
# offsetting the rule ids.
|
|
1030
|
+
#
|
|
1031
|
+
# @param source_index [Hash{Symbol => Array<Integer>}, nil]
|
|
1032
|
+
# @param rule_id_offset [Integer]
|
|
1033
|
+
# @return [void]
|
|
1034
|
+
def merge_media_index!(source_index, rule_id_offset:)
|
|
1035
|
+
return if source_index.nil? || source_index.empty?
|
|
1036
|
+
|
|
1037
|
+
source_index.each do |media_sym, rule_ids|
|
|
1038
|
+
offsetted_ids = rule_ids.map { |id| id + rule_id_offset }
|
|
1039
|
+
if @media_index[media_sym]
|
|
1040
|
+
@media_index[media_sym].concat(offsetted_ids)
|
|
1041
|
+
else
|
|
1042
|
+
@media_index[media_sym] = offsetted_ids
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# Rebase a rule/at-rule's own id and its cross-references (Rule's
|
|
1048
|
+
# selector_list_id, and media_query_id on both Rule and AtRule) by the
|
|
1049
|
+
# given offsets, mutating it in place. Callers merging from a live
|
|
1050
|
+
# Stylesheet they don't own (e.g. concat) must dup the rule first.
|
|
1051
|
+
#
|
|
1052
|
+
# @param rule [Rule, AtRule]
|
|
1053
|
+
# @param rule_id_offset [Integer]
|
|
1054
|
+
# @param list_id_offset [Integer]
|
|
1055
|
+
# @param mq_id_offset [Integer]
|
|
1056
|
+
# @return [void]
|
|
1057
|
+
def rebase_rule!(rule, rule_id_offset:, list_id_offset: 0, mq_id_offset: 0)
|
|
1058
|
+
rule.id += rule_id_offset
|
|
1059
|
+
rule.media_query_id += mq_id_offset if rule.media_query_id
|
|
1060
|
+
return unless rule.is_a?(Rule)
|
|
1061
|
+
|
|
1062
|
+
rule.selector_list_id += list_id_offset if rule.selector_list_id
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# Build parser options for a block, enabling relative -> absolute URL
|
|
1066
|
+
# conversion when both absolute_paths and a base_uri are in effect.
|
|
1067
|
+
#
|
|
1068
|
+
# @return [Hash] Parser options
|
|
1069
|
+
def build_parse_options(effective_base_uri, effective_absolute_paths)
|
|
1070
|
+
parse_options = @parser_options.dup
|
|
1071
|
+
if effective_absolute_paths && effective_base_uri
|
|
1072
|
+
parse_options[:base_uri] = effective_base_uri
|
|
1073
|
+
parse_options[:absolute_paths] = true
|
|
1074
|
+
parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
1075
|
+
end
|
|
1076
|
+
parse_options
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# Merge a freshly parsed CSS block's result into this stylesheet's
|
|
1080
|
+
# rules, media queries, selector/media-query lists, and index, then
|
|
1081
|
+
# resolve any @import statements it contained.
|
|
1082
|
+
#
|
|
1083
|
+
# @param result [Hash] Return value of Cataract._parse_css
|
|
1084
|
+
# @return [void]
|
|
1085
|
+
def merge_parsed_block!(result, effective_base_uri, effective_base_dir)
|
|
1086
|
+
offset = @_last_rule_id || 0
|
|
1087
|
+
|
|
1088
|
+
list_id_offset = merge_selector_lists!(result[:_selector_lists], rule_id_offset: offset)
|
|
1089
|
+
mq_id_offset = merge_media_queries!(result[:media_queries])
|
|
1090
|
+
merge_media_query_lists!(result[:_media_query_lists], mq_id_offset: mq_id_offset)
|
|
1091
|
+
|
|
1092
|
+
new_rules = result[:rules]
|
|
1093
|
+
new_rules.each do |rule|
|
|
1094
|
+
rebase_rule!(rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
|
|
1095
|
+
@rules << rule
|
|
1096
|
+
end
|
|
1097
|
+
@_last_rule_id = offset + new_rules.length
|
|
1098
|
+
|
|
1099
|
+
merge_media_index!(result[:_media_index], rule_id_offset: offset)
|
|
1100
|
+
merge_block_imports!(result[:imports], offset, mq_id_offset, effective_base_uri, effective_base_dir)
|
|
1101
|
+
|
|
1102
|
+
@charset ||= result[:charset]
|
|
1103
|
+
@_has_nesting = result[:_has_nesting]
|
|
1104
|
+
|
|
1105
|
+
clear_memoized_caches
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# Merge a freshly parsed block's @import statements into @imports (with
|
|
1109
|
+
# offsetted IDs), then kick off import resolution if enabled.
|
|
1110
|
+
#
|
|
1111
|
+
# @return [void]
|
|
1112
|
+
def merge_block_imports!(new_imports, offset, mq_id_offset, effective_base_uri, effective_base_dir)
|
|
1113
|
+
return unless new_imports
|
|
1049
1114
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1115
|
+
new_imports.each do |import|
|
|
1116
|
+
import.id += offset
|
|
1117
|
+
import.media_query_id += mq_id_offset if import.media_query_id
|
|
1118
|
+
@imports << import
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
return unless @options[:import]
|
|
1122
|
+
|
|
1123
|
+
if @options[:import].is_a?(Hash)
|
|
1124
|
+
imported_urls = @options[:import][:imported_urls] || []
|
|
1125
|
+
depth = @options[:import][:depth] || 0
|
|
1126
|
+
else
|
|
1127
|
+
imported_urls = []
|
|
1128
|
+
depth = 0
|
|
1054
1129
|
end
|
|
1055
1130
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1131
|
+
import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
|
|
1132
|
+
import_opts[:base_uri] = effective_base_uri if effective_base_uri
|
|
1133
|
+
import_opts[:base_path] = effective_base_dir if effective_base_dir
|
|
1134
|
+
|
|
1135
|
+
resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
# Remove MediaQuery objects no longer referenced by any rule, renumbering
|
|
1139
|
+
# the ones that remain so mq.id keeps matching its position in
|
|
1140
|
+
# @media_queries. Lookups elsewhere (parser, serializer) treat
|
|
1141
|
+
# @media_queries[id] as positional, so simply compacting the array
|
|
1142
|
+
# without renumbering would desync mq.id from rule.media_query_id and
|
|
1143
|
+
# @_media_query_lists as soon as an earlier entry gets removed.
|
|
1144
|
+
#
|
|
1145
|
+
# Shared by #remove_rules! (via self) and #- (via result.send, since it
|
|
1146
|
+
# operates on a separate Stylesheet instance).
|
|
1147
|
+
#
|
|
1148
|
+
# @return [void]
|
|
1149
|
+
def compact_media_queries!
|
|
1150
|
+
# Rule and AtRule both define media_query_id directly (it's a member of
|
|
1151
|
+
# both structs), so every element of @rules responds to it - no need
|
|
1152
|
+
# for a respond_to?/is_a? guard here. filter_map keeps only rules that
|
|
1153
|
+
# actually have one set (i.e. are scoped to a media query).
|
|
1154
|
+
used_mq_ids = @rules.filter_map(&:media_query_id).to_set
|
|
1155
|
+
|
|
1058
1156
|
old_to_new_mq_id = {}
|
|
1059
1157
|
kept_mqs = []
|
|
1060
|
-
|
|
1158
|
+
@media_queries.each do |mq|
|
|
1061
1159
|
next unless used_mq_ids.include?(mq.id)
|
|
1062
1160
|
|
|
1063
1161
|
old_to_new_mq_id[mq.id] = kept_mqs.size
|
|
1064
1162
|
mq.id = kept_mqs.size
|
|
1065
1163
|
kept_mqs << mq
|
|
1066
1164
|
end
|
|
1165
|
+
@media_queries = kept_mqs
|
|
1067
1166
|
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
|
1167
|
+
@rules.each do |rule|
|
|
1168
|
+
rule.media_query_id = old_to_new_mq_id[rule.media_query_id] if rule.media_query_id
|
|
1076
1169
|
end
|
|
1077
1170
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
end
|
|
1171
|
+
@_media_query_lists.each_value { |mq_ids| mq_ids.map! { |mq_id| old_to_new_mq_id[mq_id] }.compact! }
|
|
1172
|
+
@_media_query_lists.delete_if { |_list_id, mq_ids| mq_ids.empty? }
|
|
1173
|
+
end
|
|
1082
1174
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1175
|
+
# Filter rules down to those relevant for the given media type(s).
|
|
1176
|
+
#
|
|
1177
|
+
# Shared by #to_s and #to_formatted_s so both serialize the same rule set
|
|
1178
|
+
# for a given filter. Base rules (not inside any @media block) always
|
|
1179
|
+
# apply regardless of media context, so they're included alongside any
|
|
1180
|
+
# explicitly requested media types.
|
|
1181
|
+
#
|
|
1182
|
+
# @param media [Symbol, Array<Symbol>] Media type(s) to filter to
|
|
1183
|
+
# @return [Array<Rule>] Filtered rules, in original order
|
|
1184
|
+
def filter_rules_by_media(media)
|
|
1185
|
+
which_media_array = media.is_a?(Array) ? media : [media]
|
|
1186
|
+
return @rules if which_media_array.include?(:all)
|
|
1085
1187
|
|
|
1086
|
-
#
|
|
1087
|
-
result.instance_variable_set(:@selectors, nil)
|
|
1088
|
-
result.instance_variable_set(:@_hash, nil)
|
|
1188
|
+
mi = media_index # Build media_index if needed
|
|
1089
1189
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1190
|
+
# Base rules (not in any media query) apply to all media contexts.
|
|
1191
|
+
# Built as a Set so the membership check below is O(1) per rule instead
|
|
1192
|
+
# of the O(n) scan an Array#- would do, and to avoid materializing a
|
|
1193
|
+
# separate 0..N array of every rule id just to subtract from it.
|
|
1194
|
+
media_rule_id_set = Set.new
|
|
1195
|
+
mi.each_value { |ids| media_rule_id_set.merge(ids) }
|
|
1196
|
+
matching_rule_ids = (0...@rules.length).reject { |rule_id| media_rule_id_set.include?(rule_id) }
|
|
1092
1197
|
|
|
1093
|
-
|
|
1198
|
+
# Include rules from requested media types
|
|
1199
|
+
which_media_array.each do |media_sym|
|
|
1200
|
+
matching_rule_ids.concat(mi[media_sym]) if mi[media_sym]
|
|
1201
|
+
end
|
|
1202
|
+
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
1094
1203
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
# This is an implementation detail and should not be relied upon by external code.
|
|
1098
|
-
# @return [Hash<Symbol, Array<Integer>>]
|
|
1099
|
-
attr_reader :_media_index
|
|
1204
|
+
matching_rule_ids.sort!.map! { |rule_id| @rules[rule_id] }
|
|
1205
|
+
end
|
|
1100
1206
|
|
|
1101
1207
|
# Resolve @import statements by fetching and merging imported stylesheets
|
|
1102
1208
|
#
|
|
@@ -1120,146 +1226,150 @@ module Cataract
|
|
|
1120
1226
|
imports.each do |import|
|
|
1121
1227
|
next if import.resolved # Skip already resolved imports
|
|
1122
1228
|
|
|
1123
|
-
|
|
1124
|
-
|
|
1229
|
+
resolve_single_import!(import, opts, fetcher, imported_urls, depth)
|
|
1230
|
+
end
|
|
1125
1231
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1232
|
+
# Renumber all rule IDs to be sequential in document order.
|
|
1233
|
+
# This is O(n) and very fast (~1ms for 30k rules). Only needed if we
|
|
1234
|
+
# actually resolved imports.
|
|
1235
|
+
renumber_after_import_resolution! if imports.length > 0
|
|
1236
|
+
end
|
|
1128
1237
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1238
|
+
# Fetch, parse, and merge one @import statement's target stylesheet into
|
|
1239
|
+
# this one, inserted at the import's original document position.
|
|
1240
|
+
#
|
|
1241
|
+
# @return [void]
|
|
1242
|
+
def resolve_single_import!(import, opts, fetcher, imported_urls, depth)
|
|
1243
|
+
url = import.url
|
|
1244
|
+
import_media_query_id = import.media_query_id
|
|
1131
1245
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1246
|
+
# Validate URL
|
|
1247
|
+
ImportResolver.validate_url(url, opts)
|
|
1134
1248
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
imported_urls_copy << url
|
|
1249
|
+
# Check for circular references
|
|
1250
|
+
raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
|
|
1138
1251
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
|
|
1252
|
+
# Fetch imported CSS
|
|
1253
|
+
imported_css = fetcher.call(url, opts)
|
|
1142
1254
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
|
|
1147
|
-
}
|
|
1255
|
+
# Parse imported CSS recursively
|
|
1256
|
+
imported_urls_copy = imported_urls.dup
|
|
1257
|
+
imported_urls_copy << url
|
|
1148
1258
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
parse_opts[:base_uri] = imported_base_uri
|
|
1153
|
-
parse_opts[:uri_resolver] = opts[:uri_resolver]
|
|
1154
|
-
end
|
|
1259
|
+
# Determine the base URI for the imported file
|
|
1260
|
+
# This becomes the new base for resolving relative URLs in the imported CSS
|
|
1261
|
+
imported_base_uri = ImportResolver.normalize_url(url, base_path: opts[:base_path], base_uri: opts[:base_uri]).to_s
|
|
1155
1262
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
end
|
|
1263
|
+
# Build parse options for imported CSS
|
|
1264
|
+
parse_opts = {
|
|
1265
|
+
import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1, base_uri: imported_base_uri),
|
|
1266
|
+
parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
|
|
1267
|
+
}
|
|
1162
1268
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
|
1197
|
-
else
|
|
1198
|
-
# Rule has no media query - just assign the import's media query
|
|
1199
|
-
rule.media_query_id = import_media_query_id
|
|
1200
|
-
end
|
|
1201
|
-
end
|
|
1202
|
-
end
|
|
1269
|
+
# If URL conversion is enabled (base_uri present), enable it for imported files too
|
|
1270
|
+
if opts[:base_uri]
|
|
1271
|
+
parse_opts[:absolute_paths] = true
|
|
1272
|
+
parse_opts[:base_uri] = imported_base_uri
|
|
1273
|
+
parse_opts[:uri_resolver] = opts[:uri_resolver]
|
|
1274
|
+
end
|
|
1203
1275
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1276
|
+
# Pass parent import's media query context to parser so nested imports can combine
|
|
1277
|
+
if import_media_query_id
|
|
1278
|
+
parent_mq = @media_queries[import_media_query_id]
|
|
1279
|
+
parse_opts[:parser][:parent_import_media_type] = parent_mq.type
|
|
1280
|
+
parse_opts[:parser][:parent_import_media_conditions] = parent_mq.conditions
|
|
1281
|
+
end
|
|
1207
1282
|
|
|
1208
|
-
|
|
1209
|
-
imported_sheet.rules.each_with_index do |rule, idx|
|
|
1210
|
-
@rules.insert(insert_position + idx, rule)
|
|
1211
|
-
end
|
|
1283
|
+
imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
|
|
1212
1284
|
|
|
1213
|
-
|
|
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)
|
|
1217
|
-
else
|
|
1218
|
-
@media_index[media_sym] = rule_ids.dup
|
|
1219
|
-
end
|
|
1220
|
-
end
|
|
1285
|
+
merge_imported_sheet!(imported_sheet, import)
|
|
1221
1286
|
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
|
1287
|
+
# Merge charset (first one wins per CSS spec)
|
|
1288
|
+
@charset ||= imported_sheet.charset
|
|
1232
1289
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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
|
|
1240
|
-
end
|
|
1241
|
-
@_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
|
|
1242
|
-
end
|
|
1290
|
+
# Mark as resolved
|
|
1291
|
+
import.resolved = true
|
|
1292
|
+
end
|
|
1243
1293
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1294
|
+
# Merge an already-parsed imported stylesheet's media queries, rules,
|
|
1295
|
+
# and selector/media-query lists into this one. Rules are inserted at
|
|
1296
|
+
# the importing @import statement's original document position; rule
|
|
1297
|
+
# ids (and selector_lists' rule-id references) are left unoffset here
|
|
1298
|
+
# and fixed up in one bulk pass once all imports are resolved, since
|
|
1299
|
+
# positional insertion shifts everything after it anyway.
|
|
1300
|
+
#
|
|
1301
|
+
# @return [void]
|
|
1302
|
+
def merge_imported_sheet!(imported_sheet, import)
|
|
1303
|
+
mq_id_offset = merge_media_queries!(imported_sheet.media_queries)
|
|
1304
|
+
rebase_imported_rules_media_query!(imported_sheet.rules, mq_id_offset, import.media_query_id)
|
|
1246
1305
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1306
|
+
insert_position = import.id
|
|
1307
|
+
imported_sheet.rules.each_with_index do |rule, idx|
|
|
1308
|
+
@rules.insert(insert_position + idx, rule)
|
|
1249
1309
|
end
|
|
1250
1310
|
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
return unless imports.length > 0
|
|
1311
|
+
merge_selector_lists!(imported_sheet.selector_lists)
|
|
1312
|
+
merge_media_query_lists!(imported_sheet.media_query_lists, mq_id_offset: mq_id_offset)
|
|
1313
|
+
end
|
|
1255
1314
|
|
|
1315
|
+
# Rebase every imported rule/at-rule's media_query_id onto this
|
|
1316
|
+
# stylesheet's own @media_queries (now that the imported sheet's own
|
|
1317
|
+
# media queries have been merged in), then, if the @import statement
|
|
1318
|
+
# itself had a media qualifier, combine it with (or assign it to) each
|
|
1319
|
+
# rule's media context.
|
|
1320
|
+
#
|
|
1321
|
+
# @return [void]
|
|
1322
|
+
def rebase_imported_rules_media_query!(imported_rules, mq_id_offset, import_media_query_id)
|
|
1323
|
+
imported_rules.each { |rule| rebase_rule!(rule, rule_id_offset: 0, mq_id_offset: mq_id_offset) }
|
|
1324
|
+
|
|
1325
|
+
return unless import_media_query_id
|
|
1326
|
+
|
|
1327
|
+
import_mq = @media_queries[import_media_query_id]
|
|
1328
|
+
|
|
1329
|
+
imported_rules.each do |rule|
|
|
1330
|
+
next unless rule.is_a?(Rule)
|
|
1331
|
+
|
|
1332
|
+
if rule.media_query_id
|
|
1333
|
+
# Rule already has a media query - need to combine them
|
|
1334
|
+
# Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
|
|
1335
|
+
# Result: screen and (max-width: 768px)
|
|
1336
|
+
existing_mq = @media_queries[rule.media_query_id]
|
|
1337
|
+
|
|
1338
|
+
# The type is always the import's type (leftmost)
|
|
1339
|
+
combined_type = import_mq.type
|
|
1340
|
+
combined_conditions = if import_mq.conditions && existing_mq.conditions
|
|
1341
|
+
"#{import_mq.conditions} and #{existing_mq.conditions}"
|
|
1342
|
+
elsif import_mq.conditions
|
|
1343
|
+
"#{import_mq.conditions} and #{existing_mq.text}"
|
|
1344
|
+
elsif existing_mq.conditions
|
|
1345
|
+
existing_mq.conditions
|
|
1346
|
+
else
|
|
1347
|
+
existing_mq.text
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
|
|
1351
|
+
@media_queries << combined_mq
|
|
1352
|
+
rule.media_query_id = @_next_media_query_id
|
|
1353
|
+
@_next_media_query_id += 1
|
|
1354
|
+
else
|
|
1355
|
+
# Rule has no media query - just assign the import's media query
|
|
1356
|
+
rule.media_query_id = import_media_query_id
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
# After all imports for this call are resolved, renumber every rule
|
|
1362
|
+
# (including at-rules) and import statement placeholder to sequential
|
|
1363
|
+
# ids matching final document order, and propagate the same mapping to
|
|
1364
|
+
# selector_lists' rule-id references.
|
|
1365
|
+
#
|
|
1366
|
+
# @return [void]
|
|
1367
|
+
def renumber_after_import_resolution!
|
|
1256
1368
|
# Single-pass renumbering: build old->new mapping while renumbering
|
|
1257
1369
|
old_to_new_id = {}
|
|
1258
1370
|
@rules.each_with_index do |rule, new_idx|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
rule.id = new_idx
|
|
1262
|
-
end
|
|
1371
|
+
old_to_new_id[rule.id] = new_idx
|
|
1372
|
+
rule.id = new_idx
|
|
1263
1373
|
end
|
|
1264
1374
|
|
|
1265
1375
|
# Update rule IDs in selector_lists (only if we have any)
|
|
@@ -1276,32 +1386,6 @@ module Cataract
|
|
|
1276
1386
|
@media_index = {}
|
|
1277
1387
|
end
|
|
1278
1388
|
|
|
1279
|
-
# Check if a rule matches any of the requested media queries
|
|
1280
|
-
#
|
|
1281
|
-
# @param rule_id [Integer] Rule ID to check
|
|
1282
|
-
# @param query_media [Array<Symbol>] Media types to match
|
|
1283
|
-
# @return [Boolean] true if rule appears in any of the requested media index entries
|
|
1284
|
-
def rule_matches_media?(rule_id, query_media)
|
|
1285
|
-
query_media.any? { |m| media_index[m]&.include?(rule_id) }
|
|
1286
|
-
end
|
|
1287
|
-
|
|
1288
|
-
# Check if a rule matches the specificity filter
|
|
1289
|
-
#
|
|
1290
|
-
# @param rule [Rule] Rule to check
|
|
1291
|
-
# @param specificity [Integer, Range] Specificity filter
|
|
1292
|
-
# @return [Boolean] true if rule matches specificity
|
|
1293
|
-
def rule_matches_specificity?(rule, specificity)
|
|
1294
|
-
# Skip rules with nil specificity (e.g., AtRule)
|
|
1295
|
-
return false if rule.specificity.nil?
|
|
1296
|
-
|
|
1297
|
-
case specificity
|
|
1298
|
-
when Range
|
|
1299
|
-
specificity.cover?(rule.specificity)
|
|
1300
|
-
else
|
|
1301
|
-
specificity == rule.specificity
|
|
1302
|
-
end
|
|
1303
|
-
end
|
|
1304
|
-
|
|
1305
1389
|
# Clear memoized caches that can be lazily rebuilt.
|
|
1306
1390
|
#
|
|
1307
1391
|
# Call this method after any operation that modifies the stylesheet's rules
|
|
@@ -1350,19 +1434,5 @@ module Cataract
|
|
|
1350
1434
|
|
|
1351
1435
|
props_by_media
|
|
1352
1436
|
end
|
|
1353
|
-
|
|
1354
|
-
# Check if a rule has a declaration matching property and/or value
|
|
1355
|
-
#
|
|
1356
|
-
# @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
|
|
1357
|
-
# @param property [String, nil] Property name to match
|
|
1358
|
-
# @param property_value [String, nil] Property value to match
|
|
1359
|
-
# @return [Boolean] true if rule has matching declaration
|
|
1360
|
-
def rule_matches_property?(rule, property, property_value)
|
|
1361
|
-
rule.declarations.any? do |decl|
|
|
1362
|
-
property_matches = property.nil? || decl.property == property
|
|
1363
|
-
value_matches = property_value.nil? || decl.value == property_value
|
|
1364
|
-
property_matches && value_matches
|
|
1365
|
-
end
|
|
1366
|
-
end
|
|
1367
1437
|
end
|
|
1368
1438
|
end
|