cataract 0.2.5 → 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/CHANGELOG.md +19 -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 +247 -454
- data/ext/cataract/cataract.h +126 -15
- data/ext/cataract/css_parser.c +782 -788
- 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/import_resolver.rb +36 -16
- data/lib/cataract/pure/byte_constants.rb +0 -1
- data/lib/cataract/pure/flatten.rb +143 -145
- data/lib/cataract/pure/parser.rb +495 -422
- data/lib/cataract/pure/serializer.rb +109 -104
- data/lib/cataract/pure/specificity.rb +112 -156
- data/lib/cataract/pure.rb +0 -7
- data/lib/cataract/stylesheet.rb +460 -412
- data/lib/cataract/version.rb +1 -1
- metadata +2 -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
|
@@ -169,16 +169,17 @@ module Cataract
|
|
|
169
169
|
# @param source [Stylesheet] Source stylesheet being copied
|
|
170
170
|
def initialize_copy(source)
|
|
171
171
|
super
|
|
172
|
-
@
|
|
173
|
-
@
|
|
174
|
-
@
|
|
175
|
-
@
|
|
176
|
-
@
|
|
177
|
-
@
|
|
178
|
-
@
|
|
179
|
-
@
|
|
180
|
-
@
|
|
181
|
-
@
|
|
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
|
|
182
183
|
clear_memoized_caches
|
|
183
184
|
@_hash = nil # Clear cached hash
|
|
184
185
|
end
|
|
@@ -198,11 +199,12 @@ module Cataract
|
|
|
198
199
|
#
|
|
199
200
|
# @param filename [String] Path to the CSS file
|
|
200
201
|
# @param base_dir [String] Base directory for resolving the filename (default: '.')
|
|
201
|
-
# @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)
|
|
202
204
|
# @return [Stylesheet] A new Stylesheet containing the parsed CSS
|
|
203
205
|
def self.load_file(filename, base_dir = '.', **options)
|
|
204
206
|
sheet = new(options)
|
|
205
|
-
sheet.load_file(filename, base_dir)
|
|
207
|
+
sheet.load_file(filename, base_dir, options)
|
|
206
208
|
sheet
|
|
207
209
|
end
|
|
208
210
|
|
|
@@ -445,49 +447,26 @@ module Cataract
|
|
|
445
447
|
#
|
|
446
448
|
# @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
|
|
447
449
|
# - :all - Output all rules including base rules and all media queries
|
|
448
|
-
# - :screen, :print, etc. - Output
|
|
449
|
-
# - [: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
|
|
450
452
|
#
|
|
451
453
|
# Important: When filtering to specific media types, base rules (rules not
|
|
452
|
-
# inside any @media block) are
|
|
453
|
-
#
|
|
454
|
+
# inside any @media block) are always included, since they apply regardless
|
|
455
|
+
# of media context. Only rules from OTHER @media queries are excluded.
|
|
454
456
|
# @return [String] CSS string
|
|
455
457
|
#
|
|
456
458
|
# @example Get all CSS
|
|
457
459
|
# sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
458
460
|
# sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
459
461
|
#
|
|
460
|
-
# @example Filter to specific media type (
|
|
461
|
-
# sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
|
|
462
|
-
# # 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
|
|
463
465
|
#
|
|
464
466
|
# @example Filter to multiple media types
|
|
465
467
|
# sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
|
|
466
468
|
def to_s(media: :all)
|
|
467
|
-
|
|
468
|
-
# Normalize to array for consistent filtering
|
|
469
|
-
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
470
|
-
|
|
471
|
-
# If :all is present, return everything (no filtering)
|
|
472
|
-
if which_media_array.include?(:all)
|
|
473
|
-
Cataract.stylesheet_to_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
474
|
-
else
|
|
475
|
-
# Collect all rule IDs that match the requested media types
|
|
476
|
-
matching_rule_ids = []
|
|
477
|
-
mi = media_index # Build media_index if needed
|
|
478
|
-
which_media_array.each do |media_sym|
|
|
479
|
-
if mi[media_sym]
|
|
480
|
-
matching_rule_ids.concat(mi[media_sym])
|
|
481
|
-
end
|
|
482
|
-
end
|
|
483
|
-
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
484
|
-
|
|
485
|
-
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
486
|
-
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
487
|
-
|
|
488
|
-
# Serialize with filtered data
|
|
489
|
-
Cataract.stylesheet_to_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
490
|
-
end
|
|
469
|
+
Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
491
470
|
end
|
|
492
471
|
alias to_css to_s
|
|
493
472
|
|
|
@@ -497,10 +476,10 @@ module Cataract
|
|
|
497
476
|
# Rules are formatted with each declaration on its own line, and media queries
|
|
498
477
|
# are properly indented. Optionally filters output to specific media queries.
|
|
499
478
|
#
|
|
500
|
-
# @param
|
|
479
|
+
# @param media [Symbol, Array<Symbol>] Optional media filter (default: :all)
|
|
501
480
|
# - :all - Output all rules including base rules and all media queries
|
|
502
|
-
# - :screen, :print, etc. - Output
|
|
503
|
-
# - [: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
|
|
504
483
|
#
|
|
505
484
|
# @return [String] Formatted CSS string
|
|
506
485
|
#
|
|
@@ -508,43 +487,12 @@ module Cataract
|
|
|
508
487
|
# sheet.to_formatted_s
|
|
509
488
|
# # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
|
|
510
489
|
#
|
|
511
|
-
# @example Filter to specific media type
|
|
512
|
-
# sheet.to_formatted_s(:print)
|
|
490
|
+
# @example Filter to specific media type (base rules still included)
|
|
491
|
+
# sheet.to_formatted_s(media: :print)
|
|
513
492
|
#
|
|
514
493
|
# @see #to_s For compact single-line output
|
|
515
494
|
def to_formatted_s(media: :all)
|
|
516
|
-
|
|
517
|
-
# Normalize to array for consistent filtering
|
|
518
|
-
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
519
|
-
|
|
520
|
-
# If :all is present, return everything (no filtering)
|
|
521
|
-
if which_media_array.include?(:all)
|
|
522
|
-
Cataract.stylesheet_to_formatted_s(@rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
523
|
-
else
|
|
524
|
-
# Collect all rule IDs that match the requested media types
|
|
525
|
-
matching_rule_ids = []
|
|
526
|
-
mi = media_index # Build media_index if needed
|
|
527
|
-
|
|
528
|
-
# Include rules not in any media query (they apply to all media)
|
|
529
|
-
media_rule_ids = mi.values.flatten.uniq
|
|
530
|
-
all_rule_ids = (0...@rules.length).to_a
|
|
531
|
-
non_media_rule_ids = all_rule_ids - media_rule_ids
|
|
532
|
-
matching_rule_ids.concat(non_media_rule_ids)
|
|
533
|
-
|
|
534
|
-
# Include rules from requested media types
|
|
535
|
-
which_media_array.each do |media_sym|
|
|
536
|
-
if mi[media_sym]
|
|
537
|
-
matching_rule_ids.concat(mi[media_sym])
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
matching_rule_ids.uniq! # Dedupe: same rule can be in multiple media indexes
|
|
541
|
-
|
|
542
|
-
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
543
|
-
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
544
|
-
|
|
545
|
-
# Serialize with filtered data
|
|
546
|
-
Cataract.stylesheet_to_formatted_s(filtered_rules, @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
547
|
-
end
|
|
495
|
+
Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
|
|
548
496
|
end
|
|
549
497
|
|
|
550
498
|
# Get number of rules
|
|
@@ -578,14 +526,15 @@ module Cataract
|
|
|
578
526
|
#
|
|
579
527
|
# @param filename [String] Path to the CSS file
|
|
580
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: [])
|
|
581
530
|
# @return [self] Returns self for method chaining
|
|
582
|
-
def load_file(filename, base_dir = '.',
|
|
531
|
+
def load_file(filename, base_dir = '.', options = {})
|
|
583
532
|
# Normalize file path and convert to file:// URI
|
|
584
533
|
file_path = File.expand_path(filename, base_dir)
|
|
585
534
|
file_uri = "file://#{file_path}"
|
|
586
535
|
|
|
587
536
|
# Delegate to load_uri which handles imports and base_path
|
|
588
|
-
load_uri(file_uri)
|
|
537
|
+
load_uri(file_uri, options)
|
|
589
538
|
end
|
|
590
539
|
|
|
591
540
|
# Load CSS from a URI and add to this stylesheet.
|
|
@@ -595,20 +544,25 @@ module Cataract
|
|
|
595
544
|
# @return [self] Returns self for method chaining
|
|
596
545
|
def load_uri(uri, options = {})
|
|
597
546
|
require 'uri'
|
|
598
|
-
require 'net/http'
|
|
599
547
|
|
|
600
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
|
|
601
559
|
css_content = nil
|
|
602
560
|
file_path = nil
|
|
603
561
|
|
|
604
562
|
case uri_obj.scheme
|
|
605
563
|
when 'http', 'https'
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
css_content = response.body
|
|
564
|
+
ImportResolver.validate_url(uri, opts)
|
|
565
|
+
css_content = fetcher.call(uri, opts)
|
|
612
566
|
when 'file', nil
|
|
613
567
|
# file:// URI or relative path
|
|
614
568
|
path = uri_obj.scheme == 'file' ? uri_obj.path : uri
|
|
@@ -625,7 +579,9 @@ module Cataract
|
|
|
625
579
|
@options[:import] = @options[:import].merge(base_path: file_dir)
|
|
626
580
|
end
|
|
627
581
|
|
|
628
|
-
|
|
582
|
+
file_uri = "file://#{file_path}"
|
|
583
|
+
ImportResolver.validate_url(file_uri, opts)
|
|
584
|
+
css_content = fetcher.call(file_uri, opts)
|
|
629
585
|
else
|
|
630
586
|
raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
|
|
631
587
|
end
|
|
@@ -726,9 +682,7 @@ module Cataract
|
|
|
726
682
|
# Clean up empty media_index entries
|
|
727
683
|
@media_index.delete_if { |_media, ids| ids.empty? }
|
|
728
684
|
|
|
729
|
-
|
|
730
|
-
used_mq_ids = @rules.filter_map { |r| r.media_query_id if r.is_a?(Rule) }.to_set
|
|
731
|
-
@media_queries.select! { |mq| used_mq_ids.include?(mq.id) }
|
|
685
|
+
compact_media_queries!
|
|
732
686
|
|
|
733
687
|
# Update rule IDs in remaining rules
|
|
734
688
|
@rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
@@ -761,119 +715,12 @@ module Cataract
|
|
|
761
715
|
effective_base_dir = base_dir || @options[:base_dir]
|
|
762
716
|
effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths
|
|
763
717
|
|
|
764
|
-
|
|
765
|
-
offset = @_last_rule_id || 0
|
|
766
|
-
|
|
767
|
-
# Build parser options with URL conversion settings
|
|
768
|
-
parse_options = @parser_options.dup
|
|
769
|
-
if effective_absolute_paths && effective_base_uri
|
|
770
|
-
parse_options[:base_uri] = effective_base_uri
|
|
771
|
-
parse_options[:absolute_paths] = true
|
|
772
|
-
parse_options[:uri_resolver] = @options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
773
|
-
end
|
|
718
|
+
parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)
|
|
774
719
|
|
|
775
720
|
# Parse CSS first (this extracts @import statements into result[:imports])
|
|
776
721
|
result = Cataract._parse_css(css, parse_options)
|
|
777
722
|
|
|
778
|
-
|
|
779
|
-
list_id_offset = @_next_selector_list_id
|
|
780
|
-
if result[:_selector_lists] && !result[:_selector_lists].empty?
|
|
781
|
-
result[:_selector_lists].each do |list_id, rule_ids|
|
|
782
|
-
new_list_id = list_id + list_id_offset
|
|
783
|
-
offsetted_rule_ids = rule_ids.map { |id| id + offset }
|
|
784
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
785
|
-
end
|
|
786
|
-
@_next_selector_list_id = list_id_offset + result[:_selector_lists].size
|
|
787
|
-
end
|
|
788
|
-
|
|
789
|
-
# Merge media_query_lists with offsetted IDs
|
|
790
|
-
media_query_id_offset = @_next_media_query_id
|
|
791
|
-
mq_list_id_offset = @_next_media_query_list_id
|
|
792
|
-
if result[:_media_query_lists] && !result[:_media_query_lists].empty?
|
|
793
|
-
result[:_media_query_lists].each do |list_id, mq_ids|
|
|
794
|
-
new_list_id = list_id + mq_list_id_offset
|
|
795
|
-
offsetted_mq_ids = mq_ids.map { |id| id + media_query_id_offset }
|
|
796
|
-
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
797
|
-
end
|
|
798
|
-
@_next_media_query_list_id = mq_list_id_offset + result[:_media_query_lists].size
|
|
799
|
-
end
|
|
800
|
-
|
|
801
|
-
# Merge rules with offsetted IDs
|
|
802
|
-
new_rules = result[:rules]
|
|
803
|
-
new_rules.each do |rule|
|
|
804
|
-
rule.id += offset
|
|
805
|
-
# Update selector_list_id to point to offsetted list (only for Rule, not AtRule)
|
|
806
|
-
if rule.is_a?(Rule) && rule.selector_list_id
|
|
807
|
-
rule.selector_list_id += list_id_offset
|
|
808
|
-
end
|
|
809
|
-
# Update media_query_id to point to offsetted MediaQuery
|
|
810
|
-
if rule.is_a?(Rule) && rule.media_query_id
|
|
811
|
-
rule.media_query_id += media_query_id_offset
|
|
812
|
-
end
|
|
813
|
-
@rules << rule
|
|
814
|
-
end
|
|
815
|
-
|
|
816
|
-
# Merge media_index with offsetted IDs
|
|
817
|
-
result[:_media_index].each do |media_sym, rule_ids|
|
|
818
|
-
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
819
|
-
if @media_index[media_sym]
|
|
820
|
-
@media_index[media_sym].concat(offsetted_ids)
|
|
821
|
-
else
|
|
822
|
-
@media_index[media_sym] = offsetted_ids
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
|
|
826
|
-
# Merge media_queries with offsetted IDs
|
|
827
|
-
if result[:media_queries]
|
|
828
|
-
result[:media_queries].each do |mq|
|
|
829
|
-
mq.id += media_query_id_offset
|
|
830
|
-
@media_queries << mq
|
|
831
|
-
end
|
|
832
|
-
@_next_media_query_id += result[:media_queries].length
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
# Update last rule ID
|
|
836
|
-
@_last_rule_id = offset + new_rules.length
|
|
837
|
-
|
|
838
|
-
# Merge imports with offsetted IDs
|
|
839
|
-
if result[:imports]
|
|
840
|
-
new_imports = result[:imports]
|
|
841
|
-
new_imports.each do |import|
|
|
842
|
-
import.id += offset
|
|
843
|
-
# Update media_query_id to point to offsetted MediaQuery
|
|
844
|
-
if import.media_query_id
|
|
845
|
-
import.media_query_id += media_query_id_offset
|
|
846
|
-
end
|
|
847
|
-
@imports << import
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
# Resolve imports if configured
|
|
851
|
-
if @options[:import]
|
|
852
|
-
# Extract imported_urls and depth from options
|
|
853
|
-
if @options[:import].is_a?(Hash)
|
|
854
|
-
imported_urls = @options[:import][:imported_urls] || []
|
|
855
|
-
depth = @options[:import][:depth] || 0
|
|
856
|
-
else
|
|
857
|
-
imported_urls = []
|
|
858
|
-
depth = 0
|
|
859
|
-
end
|
|
860
|
-
|
|
861
|
-
# Build import options with base_uri/base_dir for URL resolution
|
|
862
|
-
import_opts = @options[:import].is_a?(Hash) ? @options[:import].dup : {}
|
|
863
|
-
import_opts[:base_uri] = effective_base_uri if effective_base_uri
|
|
864
|
-
import_opts[:base_path] = effective_base_dir if effective_base_dir
|
|
865
|
-
|
|
866
|
-
resolve_imports(new_imports, import_opts, imported_urls: imported_urls, depth: depth)
|
|
867
|
-
end
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
# Set charset if not already set
|
|
871
|
-
@charset ||= result[:charset]
|
|
872
|
-
|
|
873
|
-
# Track if we have any nesting (for serialization optimization)
|
|
874
|
-
@_has_nesting = result[:_has_nesting]
|
|
875
|
-
|
|
876
|
-
clear_memoized_caches
|
|
723
|
+
merge_parsed_block!(result, effective_base_uri, effective_base_dir)
|
|
877
724
|
|
|
878
725
|
self
|
|
879
726
|
end
|
|
@@ -924,7 +771,7 @@ module Cataract
|
|
|
924
771
|
def ==(other)
|
|
925
772
|
return false unless other.is_a?(Stylesheet)
|
|
926
773
|
return false unless rules == other.rules
|
|
927
|
-
return false unless @media_queries == other.
|
|
774
|
+
return false unless @media_queries == other.media_queries
|
|
928
775
|
|
|
929
776
|
true
|
|
930
777
|
end
|
|
@@ -967,9 +814,9 @@ module Cataract
|
|
|
967
814
|
# @return [self] Returns self for method chaining
|
|
968
815
|
def flatten!
|
|
969
816
|
flattened = Cataract.flatten(self)
|
|
970
|
-
@rules = flattened.
|
|
971
|
-
@media_index = flattened.
|
|
972
|
-
@_has_nesting = flattened.
|
|
817
|
+
@rules = flattened.rules
|
|
818
|
+
@media_index = flattened.media_index_cache
|
|
819
|
+
@_has_nesting = flattened.has_nesting
|
|
973
820
|
self
|
|
974
821
|
end
|
|
975
822
|
alias cascade! flatten!
|
|
@@ -990,29 +837,23 @@ module Cataract
|
|
|
990
837
|
def concat(other)
|
|
991
838
|
raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
|
|
992
839
|
|
|
993
|
-
# Get the current offset for rule IDs
|
|
840
|
+
# Get the current offset for rule/media-query/selector-list IDs
|
|
994
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)
|
|
995
845
|
|
|
996
|
-
# Add rules with updated IDs
|
|
846
|
+
# Add rules with updated IDs and cross-references
|
|
997
847
|
other.rules.each do |rule|
|
|
998
848
|
new_rule = rule.dup
|
|
999
|
-
new_rule
|
|
849
|
+
rebase_rule!(new_rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
|
|
1000
850
|
@rules << new_rule
|
|
1001
851
|
end
|
|
1002
852
|
|
|
1003
|
-
|
|
1004
|
-
other.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
|
|
1005
|
-
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
1006
|
-
if @media_index[media_sym]
|
|
1007
|
-
@media_index[media_sym].concat(offsetted_ids)
|
|
1008
|
-
else
|
|
1009
|
-
@media_index[media_sym] = offsetted_ids
|
|
1010
|
-
end
|
|
1011
|
-
end
|
|
853
|
+
merge_media_index!(other.media_index_cache, rule_id_offset: offset)
|
|
1012
854
|
|
|
1013
855
|
# Update nesting flag if other has nesting
|
|
1014
|
-
|
|
1015
|
-
@_has_nesting = true if other_has_nesting
|
|
856
|
+
@_has_nesting = true if other.has_nesting
|
|
1016
857
|
|
|
1017
858
|
clear_memoized_caches
|
|
1018
859
|
|
|
@@ -1057,7 +898,7 @@ module Cataract
|
|
|
1057
898
|
result.rules.delete_at(idx)
|
|
1058
899
|
|
|
1059
900
|
# Update media_index: remove this rule ID and decrement higher IDs
|
|
1060
|
-
result.
|
|
901
|
+
result.media_index_cache.each_value do |ids|
|
|
1061
902
|
ids.delete(idx)
|
|
1062
903
|
ids.map! { |id| id > idx ? id - 1 : id }
|
|
1063
904
|
end
|
|
@@ -1067,58 +908,301 @@ module Cataract
|
|
|
1067
908
|
result.rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
1068
909
|
|
|
1069
910
|
# Clean up empty media_index entries
|
|
1070
|
-
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
|
|
1071
1027
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
|
1076
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
|
|
1114
|
+
|
|
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
|
|
1129
|
+
end
|
|
1130
|
+
|
|
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
|
|
1077
1155
|
|
|
1078
|
-
# Build old_id => new_id mapping
|
|
1079
|
-
# Keep MediaQuery objects that are used, maintaining their IDs
|
|
1080
1156
|
old_to_new_mq_id = {}
|
|
1081
1157
|
kept_mqs = []
|
|
1082
|
-
|
|
1158
|
+
@media_queries.each do |mq|
|
|
1083
1159
|
next unless used_mq_ids.include?(mq.id)
|
|
1084
1160
|
|
|
1085
1161
|
old_to_new_mq_id[mq.id] = kept_mqs.size
|
|
1086
1162
|
mq.id = kept_mqs.size
|
|
1087
1163
|
kept_mqs << mq
|
|
1088
1164
|
end
|
|
1165
|
+
@media_queries = kept_mqs
|
|
1089
1166
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
# Update media_query_id references in rules
|
|
1094
|
-
result.rules.each do |rule|
|
|
1095
|
-
if rule.respond_to?(:media_query_id) && rule.media_query_id
|
|
1096
|
-
rule.media_query_id = old_to_new_mq_id[rule.media_query_id]
|
|
1097
|
-
end
|
|
1167
|
+
@rules.each do |rule|
|
|
1168
|
+
rule.media_query_id = old_to_new_mq_id[rule.media_query_id] if rule.media_query_id
|
|
1098
1169
|
end
|
|
1099
1170
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
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
|
|
1104
1174
|
|
|
1105
|
-
|
|
1106
|
-
|
|
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)
|
|
1107
1187
|
|
|
1108
|
-
#
|
|
1109
|
-
result.instance_variable_set(:@selectors, nil)
|
|
1110
|
-
result.instance_variable_set(:@_hash, nil)
|
|
1188
|
+
mi = media_index # Build media_index if needed
|
|
1111
1189
|
|
|
1112
|
-
|
|
1113
|
-
|
|
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) }
|
|
1114
1197
|
|
|
1115
|
-
|
|
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
|
|
1116
1203
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
# This is an implementation detail and should not be relied upon by external code.
|
|
1120
|
-
# @return [Hash<Symbol, Array<Integer>>]
|
|
1121
|
-
attr_reader :_media_index
|
|
1204
|
+
matching_rule_ids.sort!.map! { |rule_id| @rules[rule_id] }
|
|
1205
|
+
end
|
|
1122
1206
|
|
|
1123
1207
|
# Resolve @import statements by fetching and merging imported stylesheets
|
|
1124
1208
|
#
|
|
@@ -1142,146 +1226,150 @@ module Cataract
|
|
|
1142
1226
|
imports.each do |import|
|
|
1143
1227
|
next if import.resolved # Skip already resolved imports
|
|
1144
1228
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1229
|
+
resolve_single_import!(import, opts, fetcher, imported_urls, depth)
|
|
1230
|
+
end
|
|
1147
1231
|
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
|
1150
1237
|
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
|
1153
1245
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1246
|
+
# Validate URL
|
|
1247
|
+
ImportResolver.validate_url(url, opts)
|
|
1156
1248
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
imported_urls_copy << url
|
|
1249
|
+
# Check for circular references
|
|
1250
|
+
raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
|
|
1160
1251
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
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)
|
|
1164
1254
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
parser: @parser_options.dup # Inherit parent's parser options (including selector_lists)
|
|
1169
|
-
}
|
|
1255
|
+
# Parse imported CSS recursively
|
|
1256
|
+
imported_urls_copy = imported_urls.dup
|
|
1257
|
+
imported_urls_copy << url
|
|
1170
1258
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
parse_opts[:base_uri] = imported_base_uri
|
|
1175
|
-
parse_opts[:uri_resolver] = opts[:uri_resolver]
|
|
1176
|
-
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
|
|
1177
1262
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
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
|
+
}
|
|
1184
1268
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
imported_sheet.rules.each do |rule|
|
|
1193
|
-
next unless rule.is_a?(Rule)
|
|
1194
|
-
|
|
1195
|
-
if rule.media_query_id
|
|
1196
|
-
# Rule already has a media query - need to combine them
|
|
1197
|
-
# Example: @import "mobile.css" screen; where mobile.css has @media (max-width: 768px)
|
|
1198
|
-
# Result: screen and (max-width: 768px)
|
|
1199
|
-
existing_mq = imported_sheet.media_queries[rule.media_query_id]
|
|
1200
|
-
|
|
1201
|
-
# Parse combined media query to extract type and conditions
|
|
1202
|
-
# The type is always the import's type (leftmost)
|
|
1203
|
-
combined_type = import_mq.type
|
|
1204
|
-
combined_conditions = if import_mq.conditions && existing_mq.conditions
|
|
1205
|
-
"#{import_mq.conditions} and #{existing_mq.conditions}"
|
|
1206
|
-
elsif import_mq.conditions
|
|
1207
|
-
"#{import_mq.conditions} and #{existing_mq.text}"
|
|
1208
|
-
elsif existing_mq.conditions
|
|
1209
|
-
existing_mq.conditions
|
|
1210
|
-
else
|
|
1211
|
-
existing_mq.text
|
|
1212
|
-
end
|
|
1213
|
-
|
|
1214
|
-
# Create combined MediaQuery
|
|
1215
|
-
combined_mq = MediaQuery.new(@_next_media_query_id, combined_type, combined_conditions)
|
|
1216
|
-
@media_queries << combined_mq
|
|
1217
|
-
rule.media_query_id = @_next_media_query_id
|
|
1218
|
-
@_next_media_query_id += 1
|
|
1219
|
-
else
|
|
1220
|
-
# Rule has no media query - just assign the import's media query
|
|
1221
|
-
rule.media_query_id = import_media_query_id
|
|
1222
|
-
end
|
|
1223
|
-
end
|
|
1224
|
-
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
|
|
1225
1275
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
|
1229
1282
|
|
|
1230
|
-
|
|
1231
|
-
imported_sheet.rules.each_with_index do |rule, idx|
|
|
1232
|
-
@rules.insert(insert_position + idx, rule)
|
|
1233
|
-
end
|
|
1283
|
+
imported_sheet = Stylesheet.parse(imported_css, **parse_opts)
|
|
1234
1284
|
|
|
1235
|
-
|
|
1236
|
-
imported_sheet.instance_variable_get(:@media_index).each do |media_sym, rule_ids|
|
|
1237
|
-
if @media_index[media_sym]
|
|
1238
|
-
@media_index[media_sym].concat(rule_ids)
|
|
1239
|
-
else
|
|
1240
|
-
@media_index[media_sym] = rule_ids.dup
|
|
1241
|
-
end
|
|
1242
|
-
end
|
|
1285
|
+
merge_imported_sheet!(imported_sheet, import)
|
|
1243
1286
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
imported_selector_lists = imported_sheet.instance_variable_get(:@_selector_lists)
|
|
1247
|
-
if imported_selector_lists && !imported_selector_lists.empty?
|
|
1248
|
-
imported_selector_lists.each do |list_id, rule_ids|
|
|
1249
|
-
new_list_id = list_id + list_id_offset
|
|
1250
|
-
@_selector_lists[new_list_id] = rule_ids.dup
|
|
1251
|
-
end
|
|
1252
|
-
@_next_selector_list_id = list_id_offset + imported_selector_lists.size
|
|
1253
|
-
end
|
|
1287
|
+
# Merge charset (first one wins per CSS spec)
|
|
1288
|
+
@charset ||= imported_sheet.charset
|
|
1254
1289
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if imported_mq_lists && !imported_mq_lists.empty?
|
|
1259
|
-
imported_mq_lists.each do |list_id, mq_ids|
|
|
1260
|
-
new_list_id = list_id + mq_list_id_offset
|
|
1261
|
-
@_media_query_lists[new_list_id] = mq_ids.dup
|
|
1262
|
-
end
|
|
1263
|
-
@_next_media_query_list_id = mq_list_id_offset + imported_mq_lists.size
|
|
1264
|
-
end
|
|
1290
|
+
# Mark as resolved
|
|
1291
|
+
import.resolved = true
|
|
1292
|
+
end
|
|
1265
1293
|
|
|
1266
|
-
|
|
1267
|
-
|
|
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)
|
|
1268
1305
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1306
|
+
insert_position = import.id
|
|
1307
|
+
imported_sheet.rules.each_with_index do |rule, idx|
|
|
1308
|
+
@rules.insert(insert_position + idx, rule)
|
|
1271
1309
|
end
|
|
1272
1310
|
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
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
|
|
1277
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!
|
|
1278
1368
|
# Single-pass renumbering: build old->new mapping while renumbering
|
|
1279
1369
|
old_to_new_id = {}
|
|
1280
1370
|
@rules.each_with_index do |rule, new_idx|
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
rule.id = new_idx
|
|
1284
|
-
end
|
|
1371
|
+
old_to_new_id[rule.id] = new_idx
|
|
1372
|
+
rule.id = new_idx
|
|
1285
1373
|
end
|
|
1286
1374
|
|
|
1287
1375
|
# Update rule IDs in selector_lists (only if we have any)
|
|
@@ -1298,32 +1386,6 @@ module Cataract
|
|
|
1298
1386
|
@media_index = {}
|
|
1299
1387
|
end
|
|
1300
1388
|
|
|
1301
|
-
# Check if a rule matches any of the requested media queries
|
|
1302
|
-
#
|
|
1303
|
-
# @param rule_id [Integer] Rule ID to check
|
|
1304
|
-
# @param query_media [Array<Symbol>] Media types to match
|
|
1305
|
-
# @return [Boolean] true if rule appears in any of the requested media index entries
|
|
1306
|
-
def rule_matches_media?(rule_id, query_media)
|
|
1307
|
-
query_media.any? { |m| media_index[m]&.include?(rule_id) }
|
|
1308
|
-
end
|
|
1309
|
-
|
|
1310
|
-
# Check if a rule matches the specificity filter
|
|
1311
|
-
#
|
|
1312
|
-
# @param rule [Rule] Rule to check
|
|
1313
|
-
# @param specificity [Integer, Range] Specificity filter
|
|
1314
|
-
# @return [Boolean] true if rule matches specificity
|
|
1315
|
-
def rule_matches_specificity?(rule, specificity)
|
|
1316
|
-
# Skip rules with nil specificity (e.g., AtRule)
|
|
1317
|
-
return false if rule.specificity.nil?
|
|
1318
|
-
|
|
1319
|
-
case specificity
|
|
1320
|
-
when Range
|
|
1321
|
-
specificity.cover?(rule.specificity)
|
|
1322
|
-
else
|
|
1323
|
-
specificity == rule.specificity
|
|
1324
|
-
end
|
|
1325
|
-
end
|
|
1326
|
-
|
|
1327
1389
|
# Clear memoized caches that can be lazily rebuilt.
|
|
1328
1390
|
#
|
|
1329
1391
|
# Call this method after any operation that modifies the stylesheet's rules
|
|
@@ -1372,19 +1434,5 @@ module Cataract
|
|
|
1372
1434
|
|
|
1373
1435
|
props_by_media
|
|
1374
1436
|
end
|
|
1375
|
-
|
|
1376
|
-
# Check if a rule has a declaration matching property and/or value
|
|
1377
|
-
#
|
|
1378
|
-
# @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
|
|
1379
|
-
# @param property [String, nil] Property name to match
|
|
1380
|
-
# @param property_value [String, nil] Property value to match
|
|
1381
|
-
# @return [Boolean] true if rule has matching declaration
|
|
1382
|
-
def rule_matches_property?(rule, property, property_value)
|
|
1383
|
-
rule.declarations.any? do |decl|
|
|
1384
|
-
property_matches = property.nil? || decl.property == property
|
|
1385
|
-
value_matches = property_value.nil? || decl.value == property_value
|
|
1386
|
-
property_matches && value_matches
|
|
1387
|
-
end
|
|
1388
|
-
end
|
|
1389
1437
|
end
|
|
1390
1438
|
end
|