cataract 0.1.4 → 0.2.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/ci-manual-rubies.yml +18 -1
- data/.rubocop.yml +36 -6
- data/.rubocop_todo.yml +7 -7
- data/BENCHMARKS.md +30 -30
- data/CHANGELOG.md +10 -0
- data/RAGEL_MIGRATION.md +2 -2
- data/README.md +7 -2
- data/Rakefile +24 -11
- data/cataract.gemspec +1 -1
- data/ext/cataract/cataract.c +12 -3
- data/ext/cataract/cataract.h +5 -3
- data/ext/cataract/css_parser.c +156 -32
- data/ext/cataract/extconf.rb +2 -2
- data/ext/cataract/{merge.c → flatten.c} +520 -468
- data/ext/cataract/shorthand_expander.c +164 -115
- data/lib/cataract/import_resolver.rb +60 -39
- data/lib/cataract/import_statement.rb +49 -0
- data/lib/cataract/pure/{merge.rb → flatten.rb} +39 -40
- data/lib/cataract/pure/imports.rb +13 -0
- data/lib/cataract/pure/parser.rb +108 -4
- data/lib/cataract/pure.rb +32 -9
- data/lib/cataract/rule.rb +51 -6
- data/lib/cataract/stylesheet.rb +343 -41
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +28 -24
- metadata +4 -3
data/lib/cataract/stylesheet.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Cataract
|
|
|
19
19
|
#
|
|
20
20
|
# @attr_reader [Array<Rule>] rules Array of parsed CSS rules
|
|
21
21
|
# @attr_reader [String, nil] charset The @charset declaration if present
|
|
22
|
+
# @attr_reader [Array<ImportStatement>] imports Array of @import statements
|
|
22
23
|
class Stylesheet
|
|
23
24
|
include Enumerable
|
|
24
25
|
|
|
@@ -28,6 +29,9 @@ module Cataract
|
|
|
28
29
|
# @return [String, nil] The @charset declaration if present
|
|
29
30
|
attr_reader :charset
|
|
30
31
|
|
|
32
|
+
# @return [Array<ImportStatement>] Array of @import statements
|
|
33
|
+
attr_reader :imports
|
|
34
|
+
|
|
31
35
|
# Create a new empty stylesheet.
|
|
32
36
|
#
|
|
33
37
|
# @param options [Hash] Configuration options
|
|
@@ -48,11 +52,27 @@ module Cataract
|
|
|
48
52
|
@rules = [] # Flat array of Rule structs
|
|
49
53
|
@_media_index = {} # Hash: Symbol => Array of rule IDs
|
|
50
54
|
@charset = nil
|
|
55
|
+
@imports = [] # Array of ImportStatement objects
|
|
51
56
|
@_has_nesting = nil # Set by parser (nil or boolean)
|
|
52
57
|
@_last_rule_id = nil # Tracks next rule ID for add_block
|
|
53
58
|
@selectors = nil # Memoized cache of selectors
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# Initialize copy for proper deep duplication.
|
|
62
|
+
#
|
|
63
|
+
# Ensures that dup/clone creates a proper deep copy of the stylesheet,
|
|
64
|
+
# duplicating internal arrays and hashes so mutations don't affect the original.
|
|
65
|
+
#
|
|
66
|
+
# @param source [Stylesheet] Source stylesheet being copied
|
|
67
|
+
def initialize_copy(source)
|
|
68
|
+
super
|
|
69
|
+
@rules = source.instance_variable_get(:@rules).dup
|
|
70
|
+
@imports = source.instance_variable_get(:@imports).dup
|
|
71
|
+
@_media_index = source.instance_variable_get(:@_media_index).transform_values(&:dup)
|
|
72
|
+
@selectors = nil # Clear memoized cache
|
|
73
|
+
@_hash = nil # Clear cached hash
|
|
74
|
+
end
|
|
75
|
+
|
|
56
76
|
# Parse CSS and return a new Stylesheet
|
|
57
77
|
#
|
|
58
78
|
# @param css [String] CSS string to parse
|
|
@@ -107,6 +127,12 @@ module Cataract
|
|
|
107
127
|
@rules.each(&)
|
|
108
128
|
end
|
|
109
129
|
|
|
130
|
+
def [](offset)
|
|
131
|
+
return unless @rules
|
|
132
|
+
|
|
133
|
+
@rules[offset]
|
|
134
|
+
end
|
|
135
|
+
|
|
110
136
|
# Filter rules by media query symbol(s).
|
|
111
137
|
#
|
|
112
138
|
# Returns a chainable StylesheetScope that can be further filtered.
|
|
@@ -485,37 +511,66 @@ module Cataract
|
|
|
485
511
|
self
|
|
486
512
|
end
|
|
487
513
|
|
|
488
|
-
# Remove rules
|
|
514
|
+
# Remove rules from the stylesheet
|
|
489
515
|
#
|
|
490
|
-
# @param
|
|
491
|
-
#
|
|
516
|
+
# @param rules_or_css [String, Rule, AtRule, Array<Rule, AtRule>] Rules to remove.
|
|
517
|
+
# Can be a CSS string to parse (selectors will be matched), a single Rule/AtRule object,
|
|
518
|
+
# or an array of Rule/AtRule objects.
|
|
519
|
+
# @param media_types [Symbol, Array<Symbol>, nil] Optional media types to filter removal.
|
|
520
|
+
# Only removes rules that match these media types. Pass :all to include base rules.
|
|
492
521
|
# @return [self] Returns self for method chaining
|
|
493
522
|
#
|
|
494
|
-
# @example Remove
|
|
495
|
-
# sheet.remove_rules!(
|
|
523
|
+
# @example Remove rules by CSS string
|
|
524
|
+
# sheet.remove_rules!('.header { }')
|
|
525
|
+
# sheet.remove_rules!('.header { } .footer { }')
|
|
496
526
|
#
|
|
497
527
|
# @example Remove rules from specific media type
|
|
498
|
-
# sheet.remove_rules!(
|
|
499
|
-
#
|
|
500
|
-
# @example Remove
|
|
501
|
-
# sheet.
|
|
502
|
-
|
|
528
|
+
# sheet.remove_rules!('.header { }', media_types: :screen)
|
|
529
|
+
#
|
|
530
|
+
# @example Remove specific rule objects
|
|
531
|
+
# rules = sheet.select { |r| r.selector =~ /\.btn-/ }
|
|
532
|
+
# sheet.remove_rules!(rules)
|
|
533
|
+
#
|
|
534
|
+
# @example Remove rules with media filtering
|
|
535
|
+
# sheet.remove_rules!(sheet.with_selector('.header'), media_types: :print)
|
|
536
|
+
def remove_rules!(rules_or_css, media_types: nil)
|
|
537
|
+
# Determine if we're matching by selector (CSS string) or by object identity (rule objects)
|
|
538
|
+
if rules_or_css.is_a?(String)
|
|
539
|
+
# Parse CSS string and extract selectors for matching
|
|
540
|
+
parsed = Stylesheet.parse(rules_or_css)
|
|
541
|
+
selectors_to_remove = parsed.rules.filter_map(&:selector).to_set
|
|
542
|
+
match_by_selector = true
|
|
543
|
+
else
|
|
544
|
+
# Use rule objects directly
|
|
545
|
+
rules_to_remove = rules_or_css.is_a?(Array) ? rules_or_css : [rules_or_css]
|
|
546
|
+
return self if rules_to_remove.empty?
|
|
547
|
+
|
|
548
|
+
match_by_selector = false
|
|
549
|
+
end
|
|
550
|
+
|
|
503
551
|
# Normalize media_types to array
|
|
504
552
|
filter_media = media_types ? Array(media_types).map(&:to_sym) : nil
|
|
505
553
|
|
|
506
|
-
# Find
|
|
507
|
-
|
|
554
|
+
# Find rule IDs to remove
|
|
555
|
+
rule_ids_to_remove = []
|
|
508
556
|
@rules.each_with_index do |rule, rule_id|
|
|
509
|
-
# Check
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
557
|
+
# Check if this rule matches
|
|
558
|
+
matches = if match_by_selector
|
|
559
|
+
# Match by selector for CSS string input
|
|
560
|
+
selectors_to_remove.include?(rule.selector)
|
|
561
|
+
else
|
|
562
|
+
# Match by object equality for rule collection input
|
|
563
|
+
rules_to_remove.any?(rule)
|
|
564
|
+
end
|
|
565
|
+
next unless matches
|
|
566
|
+
|
|
567
|
+
# Check media type match if filter is specified
|
|
513
568
|
if filter_media
|
|
514
569
|
rule_media_types = @_media_index.select { |_media, ids| ids.include?(rule_id) }.keys
|
|
515
570
|
# Extract individual media types from complex queries
|
|
516
571
|
individual_types = rule_media_types.flat_map { |key| Cataract.parse_media_types(key) }.uniq
|
|
517
572
|
|
|
518
|
-
# If rule is not in any media query (base rule), skip
|
|
573
|
+
# If rule is not in any media query (base rule), skip unless :all is specified
|
|
519
574
|
if individual_types.empty?
|
|
520
575
|
next unless filter_media.include?(:all)
|
|
521
576
|
else
|
|
@@ -524,11 +579,11 @@ module Cataract
|
|
|
524
579
|
end
|
|
525
580
|
end
|
|
526
581
|
|
|
527
|
-
|
|
582
|
+
rule_ids_to_remove << rule_id
|
|
528
583
|
end
|
|
529
584
|
|
|
530
585
|
# Remove rules and update media_index (sort in reverse to maintain indices during deletion)
|
|
531
|
-
|
|
586
|
+
rule_ids_to_remove.sort.reverse_each do |rule_id|
|
|
532
587
|
@rules.delete_at(rule_id)
|
|
533
588
|
|
|
534
589
|
# Remove from media_index and update IDs for rules after this one
|
|
@@ -557,7 +612,6 @@ module Cataract
|
|
|
557
612
|
# @param fix_braces [Boolean] Automatically close missing braces
|
|
558
613
|
# @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
|
|
559
614
|
# @return [self] Returns self for method chaining
|
|
560
|
-
# TODO: Move to C?
|
|
561
615
|
def add_block(css, fix_braces: false, media_types: nil)
|
|
562
616
|
css += ' }' if fix_braces && !css.strip.end_with?('}')
|
|
563
617
|
|
|
@@ -567,18 +621,11 @@ module Cataract
|
|
|
567
621
|
css = "@media #{media_list} { #{css} }"
|
|
568
622
|
end
|
|
569
623
|
|
|
570
|
-
# Resolve @import statements if configured in constructor
|
|
571
|
-
css_to_parse = if @options[:import]
|
|
572
|
-
ImportResolver.resolve(css, @options[:import])
|
|
573
|
-
else
|
|
574
|
-
css
|
|
575
|
-
end
|
|
576
|
-
|
|
577
624
|
# Get current rule ID offset
|
|
578
625
|
offset = @_last_rule_id || 0
|
|
579
626
|
|
|
580
|
-
# Parse CSS
|
|
581
|
-
result = Cataract._parse_css(
|
|
627
|
+
# Parse CSS first (this extracts @import statements into result[:imports])
|
|
628
|
+
result = Cataract._parse_css(css)
|
|
582
629
|
|
|
583
630
|
# Merge rules with offsetted IDs
|
|
584
631
|
new_rules = result[:rules]
|
|
@@ -600,6 +647,28 @@ module Cataract
|
|
|
600
647
|
# Update last rule ID
|
|
601
648
|
@_last_rule_id = offset + new_rules.length
|
|
602
649
|
|
|
650
|
+
# Merge imports with offsetted IDs
|
|
651
|
+
if result[:imports]
|
|
652
|
+
new_imports = result[:imports]
|
|
653
|
+
new_imports.each do |import|
|
|
654
|
+
import.id += offset
|
|
655
|
+
@imports << import
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Resolve imports if configured
|
|
659
|
+
if @options[:import]
|
|
660
|
+
# Extract imported_urls and depth from options
|
|
661
|
+
if @options[:import].is_a?(Hash)
|
|
662
|
+
imported_urls = @options[:import][:imported_urls] || []
|
|
663
|
+
depth = @options[:import][:depth] || 0
|
|
664
|
+
else
|
|
665
|
+
imported_urls = []
|
|
666
|
+
depth = 0
|
|
667
|
+
end
|
|
668
|
+
resolve_imports(new_imports, @options[:import], imported_urls: imported_urls, depth: depth)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
603
672
|
# Set charset if not already set
|
|
604
673
|
@charset ||= result[:charset]
|
|
605
674
|
|
|
@@ -642,33 +711,171 @@ module Cataract
|
|
|
642
711
|
end
|
|
643
712
|
end
|
|
644
713
|
|
|
645
|
-
#
|
|
714
|
+
# Compare stylesheets for equality.
|
|
715
|
+
#
|
|
716
|
+
# Two stylesheets are equal if they have the same rules in the same order
|
|
717
|
+
# and the same media queries. Rule equality uses shorthand-aware comparison.
|
|
718
|
+
# Order matters because CSS cascade depends on rule order.
|
|
719
|
+
#
|
|
720
|
+
# Charset is ignored since it's file encoding metadata, not semantic content.
|
|
721
|
+
#
|
|
722
|
+
# @param other [Object] Object to compare with
|
|
723
|
+
# @return [Boolean] true if stylesheets are equal
|
|
724
|
+
def ==(other)
|
|
725
|
+
return false unless other.is_a?(Stylesheet)
|
|
726
|
+
return false unless rules == other.rules
|
|
727
|
+
return false unless @_media_index == other.instance_variable_get(:@_media_index)
|
|
728
|
+
|
|
729
|
+
true
|
|
730
|
+
end
|
|
731
|
+
alias eql? ==
|
|
732
|
+
|
|
733
|
+
# Generate hash code for this stylesheet.
|
|
734
|
+
#
|
|
735
|
+
# Hash is based on rules and media_index to match equality semantics.
|
|
736
|
+
#
|
|
737
|
+
# @return [Integer] hash code
|
|
738
|
+
def hash
|
|
739
|
+
@_hash ||= [self.class, rules, @_media_index].hash # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Flatten all rules in this stylesheet according to CSS cascade rules.
|
|
646
743
|
#
|
|
647
744
|
# Applies specificity and !important precedence rules to compute the final
|
|
648
745
|
# set of declarations. Also recreates shorthand properties from longhand
|
|
649
746
|
# properties where possible.
|
|
650
747
|
#
|
|
651
|
-
# @return [Stylesheet] New stylesheet with
|
|
748
|
+
# @return [Stylesheet] New stylesheet with cascade applied
|
|
749
|
+
def flatten
|
|
750
|
+
Cataract.flatten(self)
|
|
751
|
+
end
|
|
752
|
+
alias cascade flatten
|
|
753
|
+
|
|
754
|
+
# Deprecated: Use flatten instead
|
|
652
755
|
def merge
|
|
653
|
-
#
|
|
654
|
-
|
|
756
|
+
warn 'Stylesheet#merge is deprecated, use #flatten instead', uplevel: 1
|
|
757
|
+
flatten
|
|
655
758
|
end
|
|
656
759
|
|
|
657
|
-
#
|
|
760
|
+
# Flatten rules in-place, mutating the receiver.
|
|
658
761
|
#
|
|
659
762
|
# This is a convenience method that updates the stylesheet's internal
|
|
660
|
-
# rules and media_index with the
|
|
661
|
-
# itself is mutated (same object_id), but note that the C
|
|
763
|
+
# rules and media_index with the flattened result. The Stylesheet object
|
|
764
|
+
# itself is mutated (same object_id), but note that the C flatten function
|
|
662
765
|
# still allocates new arrays internally.
|
|
663
766
|
#
|
|
664
767
|
# @return [self] Returns self for method chaining
|
|
665
|
-
def
|
|
666
|
-
|
|
667
|
-
@rules =
|
|
668
|
-
@_media_index =
|
|
669
|
-
@_has_nesting =
|
|
768
|
+
def flatten!
|
|
769
|
+
flattened = Cataract.flatten(self)
|
|
770
|
+
@rules = flattened.instance_variable_get(:@rules)
|
|
771
|
+
@_media_index = flattened.instance_variable_get(:@_media_index)
|
|
772
|
+
@_has_nesting = flattened.instance_variable_get(:@_has_nesting)
|
|
670
773
|
self
|
|
671
774
|
end
|
|
775
|
+
alias cascade! flatten!
|
|
776
|
+
|
|
777
|
+
# Deprecated: Use flatten! instead
|
|
778
|
+
def merge!
|
|
779
|
+
warn 'Stylesheet#merge! is deprecated, use #flatten! instead', uplevel: 1
|
|
780
|
+
flatten!
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Concatenate another stylesheet's rules into this one and apply cascade.
|
|
784
|
+
#
|
|
785
|
+
# Adds all rules from the other stylesheet to this one, then applies
|
|
786
|
+
# CSS cascade to resolve conflicts. Media queries are merged.
|
|
787
|
+
#
|
|
788
|
+
# @param other [Stylesheet] Stylesheet to concatenate
|
|
789
|
+
# @return [self] Returns self for method chaining
|
|
790
|
+
def concat(other)
|
|
791
|
+
raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
|
|
792
|
+
|
|
793
|
+
# Get the current offset for rule IDs
|
|
794
|
+
offset = @rules.length
|
|
795
|
+
|
|
796
|
+
# Add rules with updated IDs
|
|
797
|
+
other.rules.each do |rule|
|
|
798
|
+
new_rule = rule.dup
|
|
799
|
+
new_rule.id = @rules.length
|
|
800
|
+
@rules << new_rule
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Merge media_index with offsetted IDs
|
|
804
|
+
other.instance_variable_get(:@_media_index).each do |media_sym, rule_ids|
|
|
805
|
+
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
806
|
+
if @_media_index[media_sym]
|
|
807
|
+
@_media_index[media_sym].concat(offsetted_ids)
|
|
808
|
+
else
|
|
809
|
+
@_media_index[media_sym] = offsetted_ids
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# Update nesting flag if other has nesting
|
|
814
|
+
other_has_nesting = other.instance_variable_get(:@_has_nesting)
|
|
815
|
+
@_has_nesting = true if other_has_nesting
|
|
816
|
+
|
|
817
|
+
# Clear memoized cache
|
|
818
|
+
@selectors = nil
|
|
819
|
+
|
|
820
|
+
# Apply cascade in-place
|
|
821
|
+
flatten!
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Combine two stylesheets into a new one and apply cascade.
|
|
825
|
+
#
|
|
826
|
+
# Creates a new stylesheet containing rules from both stylesheets,
|
|
827
|
+
# then applies CSS cascade to resolve conflicts.
|
|
828
|
+
#
|
|
829
|
+
# @param other [Stylesheet] Stylesheet to combine with
|
|
830
|
+
# @return [Stylesheet] New stylesheet with combined and cascaded rules
|
|
831
|
+
def +(other)
|
|
832
|
+
result = dup
|
|
833
|
+
result.concat(other)
|
|
834
|
+
result
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# Remove matching rules from this stylesheet.
|
|
838
|
+
#
|
|
839
|
+
# Creates a new stylesheet with rules that don't match any rules in the
|
|
840
|
+
# other stylesheet. Uses Rule#== for matching (shorthand-aware).
|
|
841
|
+
# Does NOT apply cascade to the result.
|
|
842
|
+
#
|
|
843
|
+
# @param other [Stylesheet] Stylesheet containing rules to remove
|
|
844
|
+
# @return [Stylesheet] New stylesheet with matching rules removed
|
|
845
|
+
def -(other)
|
|
846
|
+
raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)
|
|
847
|
+
|
|
848
|
+
result = dup
|
|
849
|
+
|
|
850
|
+
# Remove matching rules using Rule#==
|
|
851
|
+
rules_to_remove_ids = []
|
|
852
|
+
result.rules.each_with_index do |rule, idx|
|
|
853
|
+
rules_to_remove_ids << idx if other.rules.include?(rule)
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Remove in reverse order to maintain indices
|
|
857
|
+
rules_to_remove_ids.reverse_each do |idx|
|
|
858
|
+
result.rules.delete_at(idx)
|
|
859
|
+
|
|
860
|
+
# Update media_index: remove this rule ID and decrement higher IDs
|
|
861
|
+
result.instance_variable_get(:@_media_index).each_value do |ids|
|
|
862
|
+
ids.delete(idx)
|
|
863
|
+
ids.map! { |id| id > idx ? id - 1 : id }
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Re-index remaining rules
|
|
868
|
+
result.rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
869
|
+
|
|
870
|
+
# Clean up empty media_index entries
|
|
871
|
+
result.instance_variable_get(:@_media_index).delete_if { |_media, ids| ids.empty? }
|
|
872
|
+
|
|
873
|
+
# Clear memoized cache
|
|
874
|
+
result.instance_variable_set(:@selectors, nil)
|
|
875
|
+
result.instance_variable_set(:@_hash, nil)
|
|
876
|
+
|
|
877
|
+
result
|
|
878
|
+
end
|
|
672
879
|
|
|
673
880
|
private
|
|
674
881
|
|
|
@@ -678,6 +885,101 @@ module Cataract
|
|
|
678
885
|
# @return [Hash<Symbol, Array<Integer>>]
|
|
679
886
|
attr_reader :_media_index
|
|
680
887
|
|
|
888
|
+
# Resolve @import statements by fetching and merging imported stylesheets
|
|
889
|
+
#
|
|
890
|
+
# @param imports [Array<ImportStatement>] Import statements to resolve
|
|
891
|
+
# @param options [Hash] Import resolution options
|
|
892
|
+
# @param imported_urls [Array<String>] URLs already imported (for circular detection)
|
|
893
|
+
# @param depth [Integer] Current import depth (for depth limit)
|
|
894
|
+
# @return [void]
|
|
895
|
+
def resolve_imports(imports, options, imported_urls: [], depth: 0)
|
|
896
|
+
# Normalize options with safe defaults
|
|
897
|
+
opts = ImportResolver.normalize_options(options)
|
|
898
|
+
|
|
899
|
+
# Check depth limit
|
|
900
|
+
if depth > opts[:max_depth]
|
|
901
|
+
raise ImportError, "Import nesting too deep: exceeded maximum depth of #{opts[:max_depth]}"
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
# Get or create fetcher
|
|
905
|
+
fetcher = opts[:fetcher] || ImportResolver::DefaultFetcher.new
|
|
906
|
+
|
|
907
|
+
imports.each do |import|
|
|
908
|
+
next if import.resolved # Skip already resolved imports
|
|
909
|
+
|
|
910
|
+
url = import.url
|
|
911
|
+
media = import.media
|
|
912
|
+
|
|
913
|
+
# Validate URL
|
|
914
|
+
ImportResolver.validate_url(url, opts)
|
|
915
|
+
|
|
916
|
+
# Check for circular references
|
|
917
|
+
raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
|
|
918
|
+
|
|
919
|
+
# Fetch imported CSS
|
|
920
|
+
imported_css = fetcher.call(url, opts)
|
|
921
|
+
|
|
922
|
+
# Parse imported CSS recursively
|
|
923
|
+
imported_urls_copy = imported_urls.dup
|
|
924
|
+
imported_urls_copy << url
|
|
925
|
+
imported_sheet = Stylesheet.parse(imported_css, import: opts.merge(imported_urls: imported_urls_copy, depth: depth + 1))
|
|
926
|
+
|
|
927
|
+
# Wrap rules in @media if import had media query
|
|
928
|
+
if media
|
|
929
|
+
imported_sheet.rules.each do |rule|
|
|
930
|
+
# Find rule's current media (if any) from imported sheet's media index
|
|
931
|
+
# A rule may be in multiple media entries (e.g., :screen and :"screen and (min-width: 768px)")
|
|
932
|
+
# We want the most specific one (longest string)
|
|
933
|
+
# TODO: Extract this logic to a helper method to keep it consistent across codebase
|
|
934
|
+
rule_media = nil
|
|
935
|
+
imported_sheet.instance_variable_get(:@_media_index).each do |m, ids|
|
|
936
|
+
# Keep the longest/most specific media query
|
|
937
|
+
if ids.include?(rule.id) && (rule_media.nil? || m.to_s.length > rule_media.to_s.length)
|
|
938
|
+
rule_media = m
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Combine media queries: "import_media and rule_media"
|
|
943
|
+
combined_media = if rule_media
|
|
944
|
+
# Combine: "media and rule_media"
|
|
945
|
+
:"#{media} and #{rule_media}"
|
|
946
|
+
else
|
|
947
|
+
media
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
# Update media index
|
|
951
|
+
if @_media_index[combined_media]
|
|
952
|
+
@_media_index[combined_media] << rule.id
|
|
953
|
+
else
|
|
954
|
+
@_media_index[combined_media] = [rule.id]
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Merge imported rules into this stylesheet
|
|
960
|
+
# Insert at current position (before any remaining local rules)
|
|
961
|
+
insert_position = import.id
|
|
962
|
+
imported_sheet.rules.each_with_index do |rule, idx|
|
|
963
|
+
@rules.insert(insert_position + idx, rule)
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Merge media index
|
|
967
|
+
imported_sheet.instance_variable_get(:@_media_index).each do |media_sym, rule_ids|
|
|
968
|
+
if @_media_index[media_sym]
|
|
969
|
+
@_media_index[media_sym].concat(rule_ids)
|
|
970
|
+
else
|
|
971
|
+
@_media_index[media_sym] = rule_ids.dup
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Merge charset (first one wins per CSS spec)
|
|
976
|
+
@charset ||= imported_sheet.instance_variable_get(:@charset)
|
|
977
|
+
|
|
978
|
+
# Mark as resolved
|
|
979
|
+
import.resolved = true
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
|
|
681
983
|
# Check if a rule matches any of the requested media queries
|
|
682
984
|
#
|
|
683
985
|
# @param rule_id [Integer] Rule ID to check
|
data/lib/cataract/version.rb
CHANGED
data/lib/cataract.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative 'cataract/version'
|
|
|
6
6
|
require_relative 'cataract/declaration'
|
|
7
7
|
require_relative 'cataract/rule'
|
|
8
8
|
require_relative 'cataract/at_rule'
|
|
9
|
+
require_relative 'cataract/import_statement'
|
|
9
10
|
|
|
10
11
|
# Load pure Ruby or C extension based on ENV var
|
|
11
12
|
if %w[1 true].include?(ENV.fetch('CATARACT_PURE', nil)) || RUBY_ENGINE == 'jruby'
|
|
@@ -34,8 +35,8 @@ require_relative 'cataract/import_resolver'
|
|
|
34
35
|
# # Query rules
|
|
35
36
|
# sheet.select(&:selector?).each { |rule| puts "#{rule.selector}: #{rule.declarations}" }
|
|
36
37
|
#
|
|
37
|
-
# #
|
|
38
|
-
#
|
|
38
|
+
# # Flatten with cascade rules
|
|
39
|
+
# flattened = sheet.flatten
|
|
39
40
|
#
|
|
40
41
|
# @see Stylesheet Main class for working with parsed CSS
|
|
41
42
|
# @see Rule Represents individual CSS rules
|
|
@@ -73,48 +74,51 @@ module Cataract
|
|
|
73
74
|
# @see Stylesheet.parse
|
|
74
75
|
unless method_defined?(:parse_css)
|
|
75
76
|
def parse_css(css, imports: false)
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
# Pass import options to Stylesheet.parse
|
|
78
|
+
# The new flow: parse first (extract @import), then resolve them
|
|
79
|
+
if imports
|
|
80
|
+
Stylesheet.parse(css, import: imports)
|
|
81
|
+
else
|
|
82
|
+
Stylesheet.parse(css)
|
|
83
|
+
end
|
|
80
84
|
end
|
|
81
85
|
end
|
|
82
86
|
|
|
83
|
-
#
|
|
87
|
+
# Flatten CSS rules according to CSS cascade rules.
|
|
84
88
|
#
|
|
85
|
-
# Takes a Stylesheet or CSS string and
|
|
86
|
-
# precedence rules. Returns a new Stylesheet with
|
|
89
|
+
# Takes a Stylesheet or CSS string and flattens all rules according to CSS cascade
|
|
90
|
+
# precedence rules. Returns a new Stylesheet with flattened rules containing
|
|
87
91
|
# the final computed declarations.
|
|
88
92
|
#
|
|
89
|
-
# @param stylesheet_or_css [Stylesheet, String] The stylesheet to
|
|
90
|
-
# @return [Stylesheet] A new Stylesheet with
|
|
93
|
+
# @param stylesheet_or_css [Stylesheet, String] The stylesheet to flatten, or a CSS string to parse and flatten
|
|
94
|
+
# @return [Stylesheet] A new Stylesheet with flattened rules
|
|
91
95
|
#
|
|
92
|
-
#
|
|
96
|
+
# Flatten rules (in order of precedence):
|
|
93
97
|
# 1. !important declarations win over non-important
|
|
94
98
|
# 2. Higher specificity wins
|
|
95
99
|
# 3. Later declarations with same specificity and importance win
|
|
96
100
|
# 4. Shorthand properties are created from longhand when possible (e.g., margin-* -> margin)
|
|
97
101
|
#
|
|
98
|
-
# @example
|
|
102
|
+
# @example Flatten a stylesheet
|
|
99
103
|
# sheet = Cataract.parse_css(".test { color: red; } #test { color: blue; }")
|
|
100
|
-
#
|
|
101
|
-
#
|
|
104
|
+
# flattened = Cataract.flatten(sheet)
|
|
105
|
+
# flattened.rules.first.declarations #=> [#<Declaration property="color" value="blue" important=false>]
|
|
102
106
|
#
|
|
103
|
-
# @example
|
|
107
|
+
# @example Flatten with !important
|
|
104
108
|
# sheet = Cataract.parse_css(".test { color: red !important; } #test { color: blue; }")
|
|
105
|
-
#
|
|
106
|
-
#
|
|
109
|
+
# flattened = Cataract.flatten(sheet)
|
|
110
|
+
# flattened.rules.first.declarations #=> [#<Declaration property="color" value="red" important=true>]
|
|
107
111
|
#
|
|
108
112
|
# @example Shorthand creation
|
|
109
113
|
# css = ".test { margin-top: 10px; margin-right: 10px; margin-bottom: 10px; margin-left: 10px; }"
|
|
110
|
-
#
|
|
111
|
-
# #
|
|
114
|
+
# flattened = Cataract.flatten(Cataract.parse_css(css))
|
|
115
|
+
# # flattened contains single "margin: 10px" declaration instead of four longhand properties
|
|
112
116
|
#
|
|
113
117
|
# @note This is a module-level convenience method. The same functionality is available
|
|
114
|
-
# as an instance method: `stylesheet.
|
|
115
|
-
# @note Implemented in C (see ext/cataract/
|
|
118
|
+
# as an instance method: `stylesheet.flatten`
|
|
119
|
+
# @note Implemented in C (see ext/cataract/flatten.c)
|
|
116
120
|
#
|
|
117
|
-
# @see Stylesheet#
|
|
118
|
-
# Cataract.
|
|
121
|
+
# @see Stylesheet#flatten
|
|
122
|
+
# Cataract.flatten is defined in C via rb_define_module_function
|
|
119
123
|
end
|
|
120
124
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cataract
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- James Cook
|
|
@@ -55,8 +55,8 @@ files:
|
|
|
55
55
|
- ext/cataract/cataract.h
|
|
56
56
|
- ext/cataract/css_parser.c
|
|
57
57
|
- ext/cataract/extconf.rb
|
|
58
|
+
- ext/cataract/flatten.c
|
|
58
59
|
- ext/cataract/import_scanner.c
|
|
59
|
-
- ext/cataract/merge.c
|
|
60
60
|
- ext/cataract/shorthand_expander.c
|
|
61
61
|
- ext/cataract/specificity.c
|
|
62
62
|
- ext/cataract/value_splitter.c
|
|
@@ -83,11 +83,12 @@ files:
|
|
|
83
83
|
- lib/cataract/declaration.rb
|
|
84
84
|
- lib/cataract/declarations.rb
|
|
85
85
|
- lib/cataract/import_resolver.rb
|
|
86
|
+
- lib/cataract/import_statement.rb
|
|
86
87
|
- lib/cataract/pure.rb
|
|
87
88
|
- lib/cataract/pure/byte_constants.rb
|
|
89
|
+
- lib/cataract/pure/flatten.rb
|
|
88
90
|
- lib/cataract/pure/helpers.rb
|
|
89
91
|
- lib/cataract/pure/imports.rb
|
|
90
|
-
- lib/cataract/pure/merge.rb
|
|
91
92
|
- lib/cataract/pure/parser.rb
|
|
92
93
|
- lib/cataract/pure/serializer.rb
|
|
93
94
|
- lib/cataract/pure/specificity.rb
|