Class: Cataract::Stylesheet

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/cataract/pure.rb,
lib/cataract/stylesheet.rb,
ext/cataract/cataract.c

Overview

Represents a parsed CSS stylesheet with rule management and merging capabilities.

The Stylesheet class stores parsed CSS rules in a flat array structure that preserves insertion order. Media queries are tracked via an index that maps media query symbols to rule IDs, allowing efficient filtering and serialization.

Examples:

Parse and query CSS

sheet = Cataract::Stylesheet.parse("body { color: red; }")
sheet.size #=> 1
sheet.rules.first.selector #=> "body"

Work with media queries

sheet = Cataract.parse_css("@media print { .footer { color: blue; } }")
sheet.media_queries #=> [:print]
sheet.with_media(:print).first.selector #=> ".footer"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ Stylesheet

Create a new empty stylesheet.

Parameters:

  • options (Hash) (defaults to: {})

    Configuration options

Options Hash (options):

  • :import (Boolean, Hash) — default: false

    Enable @import resolution. Pass true for defaults, or a hash with:

    • :allowed_schemes [Array] URI schemes to allow (default: ['https'])
    • :extensions [Array] File extensions to allow (default: ['css'])
    • :max_depth [Integer] Maximum import nesting (default: 5)
  • :io_exceptions (Boolean) — default: true

    Whether to raise exceptions on I/O errors (file not found, network errors, etc.)

  • :base_uri (String) — default: nil

    Base URI for resolving relative URLs and @import paths. Used for both URL conversion and import resolution.

  • :base_dir (String) — default: nil

    Base directory for resolving local file @import paths.

  • :absolute_paths (Boolean) — default: false

    Convert relative URLs in url() values to absolute URLs using base_uri.

  • :uri_resolver (Proc) — default: nil

    Custom proc for resolving relative URIs. Takes (base_uri, relative_uri) and returns absolute URI string. Defaults to using Ruby's URI.parse(base).merge(relative).to_s

  • :parser (Hash) — default: {}

    Parser configuration options

    • :selector_lists [Boolean] (true) Track selector lists for W3C-compliant serialization

Raises:

  • (TypeError)


111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
# File 'lib/cataract/stylesheet.rb', line 111

def initialize(options = {})
  # Type validation
  raise TypeError, "options must be a Hash, got #{options.class}" unless options.is_a?(Hash)

  # Support :imports as alias for :import (backwards compatibility)
  options[:import] = options.delete(:imports) if options.key?(:imports) && !options.key?(:import)

  @options = {
    import: false,
    io_exceptions: true,
    base_uri: nil,
    base_dir: nil,
    absolute_paths: false,
    uri_resolver: nil,
    parser: {},
    raise_parse_errors: false
  }.merge(options)

  # Type validation for specific options
  if @options[:import_fetcher] && !@options[:import_fetcher].respond_to?(:call)
    raise TypeError, "import_fetcher must be a Proc or callable, got #{@options[:import_fetcher].class}"
  end

  if @options[:base_uri] && !@options[:base_uri].is_a?(String)
    raise TypeError, "base_uri must be a String, got #{@options[:base_uri].class}"
  end

  if @options[:uri_resolver] && !@options[:uri_resolver].respond_to?(:call)
    raise TypeError, "uri_resolver must be a Proc or callable, got #{@options[:uri_resolver].class}"
  end

  # Parser options with defaults (stored for passing to parser)
  @parser_options = {
    selector_lists: true,
    raise_parse_errors: @options[:raise_parse_errors]
  }.merge(@options[:parser] || {})

  @rules = [] # Flat array of Rule structs
  @media_queries = [] # Array of MediaQuery objects
  @_next_media_query_id = 0 # Counter for MediaQuery IDs
  @media_index = {} # Hash: Symbol => Array of rule IDs (cached index, can be rebuilt from rules)
  @_selector_lists = {} # Hash: list_id => Array of rule IDs (for "h1, h2" grouping)
  @_next_selector_list_id = 0 # Counter for selector list IDs
  @_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print" grouping)
  @_next_media_query_list_id = 0 # Counter for media query list IDs
  @charset = nil
  @imports = [] # Array of ImportStatement objects
  @_has_nesting = nil # Set by parser (nil or boolean)
  @_last_rule_id = nil # Tracks next rule ID for add_block
  @selectors = nil # Memoized cache of selectors
  @_custom_properties = nil # Memoized cache of custom properties
end

Instance Attribute Details

#charsetString? (readonly)

Returns The @charset declaration if present.

Returns:

  • (String, nil)

    The @charset declaration if present



23
24
25
# File 'lib/cataract/stylesheet.rb', line 23

def charset
  @charset
end

#importsArray<ImportStatement> (readonly)

Returns Array of @import statements.

Returns:



23
24
25
# File 'lib/cataract/stylesheet.rb', line 23

def imports
  @imports
end

#media_queriesArray<MediaQuery> (readonly)

Returns Array of media query objects.

Returns:

  • (Array<MediaQuery>)

    Array of media query objects



30
31
32
# File 'lib/cataract/stylesheet.rb', line 30

def media_queries
  @media_queries
end

#rulesArray<Rule> (readonly)

Returns Array of parsed CSS rules.

Returns:

  • (Array<Rule>)

    Array of parsed CSS rules



23
24
25
# File 'lib/cataract/stylesheet.rb', line 23

def rules
  @rules
end

Class Method Details

.load_file(filename, base_dir = '.', **options) ⇒ Stylesheet

Load CSS from a file and return a new Stylesheet.

Parameters:

  • filename (String)

    Path to the CSS file

  • base_dir (String) (defaults to: '.')

    Base directory for resolving the filename (default: '.')

  • options (Hash)

    Options passed to Stylesheet.new, and to load_file (e.g. dangerous_path_prefixes: [] to load a path load_file blocks by default)

Returns:

  • (Stylesheet)

    A new Stylesheet containing the parsed CSS



205
206
207
208
209
# File 'lib/cataract/stylesheet.rb', line 205

def self.load_file(filename, base_dir = '.', **options)
  sheet = new(options)
  sheet.load_file(filename, base_dir, options)
  sheet
end

.load_uri(uri, **options) ⇒ Stylesheet

Load CSS from a URI and return a new Stylesheet.

Parameters:

  • uri (String)

    URI to load CSS from (http://, https://, or file://)

  • options (Hash)

    Options passed to Stylesheet.new

Returns:

  • (Stylesheet)

    A new Stylesheet containing the parsed CSS



216
217
218
219
220
# File 'lib/cataract/stylesheet.rb', line 216

def self.load_uri(uri, **options)
  sheet = new(options)
  sheet.load_uri(uri, options)
  sheet
end

.parse(css, **options) ⇒ Stylesheet

Parse CSS and return a new Stylesheet

Parameters:

  • css (String)

    CSS string to parse

  • options (Hash)

    Options passed to Stylesheet.new

Returns:



192
193
194
195
196
# File 'lib/cataract/stylesheet.rb', line 192

def self.parse(css, **options)
  sheet = new(options)
  sheet.add_block(css)
  sheet
end

Instance Method Details

#+(other) ⇒ Stylesheet

Combine two stylesheets into a new one and apply cascade.

Creates a new stylesheet containing rules from both stylesheets, then applies CSS cascade to resolve conflicts.

Parameters:

  • other (Stylesheet)

    Stylesheet to combine with

Returns:

  • (Stylesheet)

    New stylesheet with combined and cascaded rules



871
872
873
874
875
# File 'lib/cataract/stylesheet.rb', line 871

def +(other)
  result = dup
  result.concat(other)
  result
end

#-(other) ⇒ Stylesheet

Remove matching rules from this stylesheet.

Creates a new stylesheet with rules that don't match any rules in the other stylesheet. Uses Rule#== for matching (shorthand-aware). Does NOT apply cascade to the result.

Parameters:

  • other (Stylesheet)

    Stylesheet containing rules to remove

Returns:

  • (Stylesheet)

    New stylesheet with matching rules removed

Raises:

  • (ArgumentError)


885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
# File 'lib/cataract/stylesheet.rb', line 885

def -(other)
  raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)

  result = dup

  # Remove matching rules using Rule#==
  rules_to_remove_ids = []
  result.rules.each_with_index do |rule, idx|
    rules_to_remove_ids << idx if other.rules.include?(rule)
  end

  # Remove in reverse order to maintain indices
  rules_to_remove_ids.reverse_each do |idx|
    result.rules.delete_at(idx)

    # Update media_index: remove this rule ID and decrement higher IDs
    result.media_index_cache.each_value do |ids|
      ids.delete(idx)
      ids.map! { |id| id > idx ? id - 1 : id }
    end
  end

  # Re-index remaining rules
  result.rules.each_with_index { |rule, new_id| rule.id = new_id }

  # Clean up empty media_index entries
  result.media_index_cache.delete_if { |_media, ids| ids.empty? }

  result.send(:compact_media_queries!)

  # Clear memoized cache
  result.send(:clear_memoized_caches)
  result._hash = nil

  result
end

#==(other) ⇒ Boolean Also known as: eql?

Compare stylesheets for equality.

Two stylesheets are equal if they have the same rules in the same order and the same media queries. Rule equality uses shorthand-aware comparison. Order matters because CSS cascade depends on rule order.

Charset is ignored since it's file encoding metadata, not semantic content.

Parameters:

  • other (Object)

    Object to compare with

Returns:

  • (Boolean)

    true if stylesheets are equal



771
772
773
774
775
776
777
# File 'lib/cataract/stylesheet.rb', line 771

def ==(other)
  return false unless other.is_a?(Stylesheet)
  return false unless rules == other.rules
  return false unless @media_queries == other.media_queries

  true
end

#[](offset) ⇒ Object



242
243
244
245
246
# File 'lib/cataract/stylesheet.rb', line 242

def [](offset)
  return unless @rules

  @rules[offset]
end

#add_block(css, fix_braces: false, media_types: nil, base_uri: nil, base_dir: nil, absolute_paths: nil) ⇒ self

Add CSS block to stylesheet

Parameters:

  • css (String)

    CSS string to add

  • fix_braces (Boolean) (defaults to: false)

    Automatically close missing braces

  • media_types (Symbol, Array<Symbol>) (defaults to: nil)

    Optional media query to wrap CSS in

  • base_uri (String, nil) (defaults to: nil)

    Override constructor's base_uri for this block

  • base_dir (String, nil) (defaults to: nil)

    Override constructor's base_dir for this block

  • absolute_paths (Boolean, nil) (defaults to: nil)

    Override constructor's absolute_paths for this block

Returns:

  • (self)

    Returns self for method chaining



704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/cataract/stylesheet.rb', line 704

def add_block(css, fix_braces: false, media_types: nil, base_uri: nil, base_dir: nil, absolute_paths: nil)
  css += ' }' if fix_braces && !css.strip.end_with?('}')

  # Convenience wrapper: wrap in @media if media_types specified
  if media_types
    media_list = Array(media_types).join(', ')
    css = "@media #{media_list} { #{css} }"
  end

  # Determine effective options (per-call overrides or constructor defaults)
  effective_base_uri = base_uri || @options[:base_uri]
  effective_base_dir = base_dir || @options[:base_dir]
  effective_absolute_paths = absolute_paths.nil? ? @options[:absolute_paths] : absolute_paths

  parse_options = build_parse_options(effective_base_uri, effective_absolute_paths)

  # Parse CSS first (this extracts @import statements into result[:imports])
  result = Cataract._parse_css(css, parse_options)

  merge_parsed_block!(result, effective_base_uri, effective_base_dir)

  self
end

#add_rule(selector:, declarations:, media_types: nil) ⇒ self

Add a single rule

Parameters:

  • selector (String)

    CSS selector

  • declarations (String)

    CSS declarations (property: value pairs)

  • media_types (Symbol, Array<Symbol>) (defaults to: nil)

    Optional media types to wrap rule in

Returns:

  • (self)

    Returns self for method chaining



734
735
736
737
738
# File 'lib/cataract/stylesheet.rb', line 734

def add_rule(selector:, declarations:, media_types: nil)
  # Wrap in CSS syntax and add as block
  css = "#{selector} { #{declarations} }"
  add_block(css, media_types: media_types)
end

#base_onlyStylesheetScope

Filter to only base rules (rules not inside any @media query).

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Get base rules only

sheet.base_only.map(&:selector)

Chain with property filter

sheet.base_only.with_property('color').to_a

Returns:



345
346
347
# File 'lib/cataract/stylesheet.rb', line 345

def base_only
  StylesheetScope.new(self, base_only: true)
end

#base_rulesArray<Rule>

Get all rules without media query (rules that apply to all media)

Returns:

  • (Array<Rule>)

    Rules with no media query



390
391
392
393
394
# File 'lib/cataract/stylesheet.rb', line 390

def base_rules
  # Rules not in any media_index entry
  media_rule_ids = media_index.values.flatten.uniq
  @rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
end

#clear!self

Clear all rules

Returns:

  • (self)

    Returns self for method chaining



517
518
519
520
521
522
523
# File 'lib/cataract/stylesheet.rb', line 517

def clear!
  @rules.clear
  @media_index.clear
  @charset = nil
  clear_memoized_caches
  self
end

#concat(other) ⇒ self

Concatenate another stylesheet's rules into this one and apply cascade.

Adds all rules from the other stylesheet to this one, then applies CSS cascade to resolve conflicts. Media queries are merged.

Parameters:

  • other (Stylesheet)

    Stylesheet to concatenate

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)


837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
# File 'lib/cataract/stylesheet.rb', line 837

def concat(other)
  raise ArgumentError, 'Argument must be a Stylesheet' unless other.is_a?(Stylesheet)

  # Get the current offset for rule/media-query/selector-list IDs
  offset = @rules.length
  list_id_offset = merge_selector_lists!(other.selector_lists, rule_id_offset: offset)
  mq_id_offset = merge_media_queries!(other.media_queries)
  merge_media_query_lists!(other.media_query_lists, mq_id_offset: mq_id_offset)

  # Add rules with updated IDs and cross-references
  other.rules.each do |rule|
    new_rule = rule.dup
    rebase_rule!(new_rule, rule_id_offset: offset, list_id_offset: list_id_offset, mq_id_offset: mq_id_offset)
    @rules << new_rule
  end

  merge_media_index!(other.media_index_cache, rule_id_offset: offset)

  # Update nesting flag if other has nesting
  @_has_nesting = true if other.has_nesting

  clear_memoized_caches

  # Apply cascade in-place
  flatten!
end

#convert_colors!(*_args) ⇒ Object

Add convert_colors! instance method to Stylesheet



145
146
147
# File 'lib/cataract/pure.rb', line 145

def convert_colors!(*_args)
  raise NotImplementedError, 'convert_colors! is only available in the native C extension'
end

#custom_properties(media: nil) ⇒ Hash{Symbol => Hash{String => String}}

Get all custom property (CSS variable) definitions organized by media context.

Returns a hash mapping media contexts to custom property hashes. Custom properties are CSS variables that start with -- (e.g., --primary-color). The :root key contains base-level properties (not inside any @media block). When the same custom property is defined multiple times within the same context, the last definition in source order is used.

Examples:

All custom properties across all contexts

css = ':root { --color: red; } @media print { :root { --color: green; } }'
sheet = Cataract::Stylesheet.parse(css)
sheet.custom_properties #=> { :root => { '--color' => 'red' }, :print => { '--color' => 'green' } }

Filter to specific media context

sheet.custom_properties(media: :print) #=> { :print => { '--color' => 'green' } }

Filter to multiple contexts

sheet.custom_properties(media: [:root, :print]) #=> { :root => {...}, :print => {...} }

Only base-level properties

css = ':root { --spacing: 8px; }'
sheet = Cataract::Stylesheet.parse(css)
sheet.custom_properties #=> { :root => { '--spacing' => '8px' } }

Parameters:

  • media (Symbol, Array<Symbol>, nil) (defaults to: nil)

    Optional filter for specific media contexts

    • nil (default) - Return all media contexts including :root
    • :root - Return only base-level properties
    • :print, :screen, etc. - Return only properties from specified media context(s)
    • [:root, :print] - Return multiple contexts

Returns:

  • (Hash{Symbol => Hash{String => String}})

    Media contexts mapped to custom properties



434
435
436
437
438
439
440
441
# File 'lib/cataract/stylesheet.rb', line 434

def custom_properties(media: nil)
  @_custom_properties ||= build_custom_properties
  return @_custom_properties if media.nil?

  # Filter by media if requested
  media_array = media.is_a?(Array) ? media : [media]
  @_custom_properties.slice(*media_array)
end

#each {|rule| ... } ⇒ Enumerator

Iterate over all rules (required by Enumerable).

Yields both selector-based rules (Rule) and at-rules (AtRule). Use rule.selector? to filter for selector-based rules only.

Examples:

Iterate over all rules

sheet.each { |rule| puts rule.selector }

Filter to selector-based rules only

sheet.select(&:selector?).each { |rule| puts rule.specificity }

Yields:

  • (rule)

    Block to execute for each rule

Yield Parameters:

Returns:

  • (Enumerator)

    Returns enumerator if no block given



236
237
238
239
240
# File 'lib/cataract/stylesheet.rb', line 236

def each(&)
  return enum_for(:each) unless block_given?

  @rules.each(&)
end

#empty?Boolean

Check if stylesheet is empty

Returns:

  • (Boolean)

    true if no rules



510
511
512
# File 'lib/cataract/stylesheet.rb', line 510

def empty?
  @rules.empty?
end

#flattenStylesheet Also known as: cascade

Flatten all rules in this stylesheet according to CSS cascade rules.

Applies specificity and !important precedence rules to compute the final set of declarations. Also recreates shorthand properties from longhand properties where possible.

Returns:

  • (Stylesheet)

    New stylesheet with cascade applied



796
797
798
# File 'lib/cataract/stylesheet.rb', line 796

def flatten
  Cataract.flatten(self)
end

#flatten!self Also known as: cascade!

Flatten rules in-place, mutating the receiver.

This is a convenience method that updates the stylesheet's internal rules and media_index with the flattened result. The Stylesheet object itself is mutated (same object_id), but note that the C flatten function still allocates new arrays internally.

Returns:

  • (self)

    Returns self for method chaining



815
816
817
818
819
820
821
# File 'lib/cataract/stylesheet.rb', line 815

def flatten!
  flattened = Cataract.flatten(self)
  @rules = flattened.rules
  @media_index = flattened.media_index_cache
  @_has_nesting = flattened.has_nesting
  self
end

#hashInteger

Generate hash code for this stylesheet.

Hash is based on rules and media_queries to match equality semantics.

Returns:

  • (Integer)

    hash code



785
786
787
# File 'lib/cataract/stylesheet.rb', line 785

def hash
  @_hash ||= [self.class, rules, @media_queries].hash # rubocop:disable Naming/MemoizedInstanceVariableName
end

#initialize_copy(source) ⇒ Object

Initialize copy for proper deep duplication.

Ensures that dup/clone creates a proper deep copy of the stylesheet, duplicating internal arrays and hashes so mutations don't affect the original.

Parameters:

  • source (Stylesheet)

    Source stylesheet being copied



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/cataract/stylesheet.rb', line 170

def initialize_copy(source)
  super
  @options = source.options.dup
  @rules = source.rules.dup
  @media_queries = source.media_queries.dup
  @_next_media_query_id = source.next_media_query_id
  @media_index = source.media_index_cache.transform_values(&:dup)
  @imports = source.imports.dup
  @_selector_lists = source.selector_lists.transform_values(&:dup)
  @_next_selector_list_id = source.next_selector_list_id
  @_media_query_lists = source.media_query_lists.transform_values(&:dup)
  @_next_media_query_list_id = source.next_media_query_list_id
  @parser_options = source.parser_options.dup
  clear_memoized_caches
  @_hash = nil # Clear cached hash
end

#inspectObject



750
751
752
753
754
755
756
757
758
759
# File 'lib/cataract/stylesheet.rb', line 750

def inspect
  total_rules = size
  if total_rules.zero?
    '#<Cataract::Stylesheet empty>'
  else
    preview = @rules.first(3).map(&:selector).join(', ')
    more = total_rules > 3 ? ', ...' : ''
    "#<Cataract::Stylesheet #{total_rules} rules: #{preview}#{more}>"
  end
end

#load_file(filename, base_dir = '.', options = {}) ⇒ self

Load CSS from a local file and add to this stylesheet.

Parameters:

  • filename (String)

    Path to the CSS file

  • base_dir (String) (defaults to: '.')

    Base directory for resolving the filename (default: '.')

  • options (Hash) (defaults to: {})

    Passed through to load_uri (e.g. dangerous_path_prefixes: [])

Returns:

  • (self)

    Returns self for method chaining



531
532
533
534
535
536
537
538
# File 'lib/cataract/stylesheet.rb', line 531

def load_file(filename, base_dir = '.', options = {})
  # Normalize file path and convert to file:// URI
  file_path = File.expand_path(filename, base_dir)
  file_uri = "file://#{file_path}"

  # Delegate to load_uri which handles imports and base_path
  load_uri(file_uri, options)
end

#load_uri(uri, options = {}) ⇒ self

Load CSS from a URI and add to this stylesheet.

Parameters:

  • uri (String)

    URI to load CSS from (http://, https://, or file://)

  • options (Hash) (defaults to: {})

    Additional options

Returns:

  • (self)

    Returns self for method chaining



545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
# File 'lib/cataract/stylesheet.rb', line 545

def load_uri(uri, options = {})
  require 'uri'

  uri_obj = URI(uri)
  # Reuse the same validation and fetch collaborator @import resolution
  # uses (ImportResolver, via DefaultFetcher/open-uri) instead of a
  # second, separate Net::HTTP implementation with no validation at all -
  # that duplicate implementation also didn't follow redirects, unlike
  # this one. LOAD_DEFAULTS (rather than SAFE_DEFAULTS) reflects that the
  # caller chose this exact URI themselves, so http/any-extension are
  # allowed by default; dangerous_path_prefixes still applies unless the
  # caller overrides it (e.g. dangerous_path_prefixes: []).
  opts = ImportResolver.normalize_options(options, defaults: ImportResolver::LOAD_DEFAULTS)
  fetcher = opts[:fetcher] || @options[:import_fetcher] || ImportResolver::DefaultFetcher.new
  css_content = nil
  file_path = nil

  case uri_obj.scheme
  when 'http', 'https'
    ImportResolver.validate_url(uri, opts)
    css_content = fetcher.call(uri, opts)
  when 'file', nil
    # file:// URI or relative path
    path = uri_obj.scheme == 'file' ? uri_obj.path : uri
    # Handle base_uri if provided
    if options[:base_uri]
      base = URI(options[:base_uri])
      path = File.join(base.path, path) if base.scheme == 'file' || base.scheme.nil?
    end
    file_path = File.expand_path(path)

    # If imports are enabled and base_path not already set, set it for resolving relative imports
    if @options[:import].is_a?(Hash) && @options[:import][:base_path].nil?
      file_dir = File.dirname(file_path)
      @options[:import] = @options[:import].merge(base_path: file_dir)
    end

    file_uri = "file://#{file_path}"
    ImportResolver.validate_url(file_uri, opts)
    css_content = fetcher.call(file_uri, opts)
  else
    raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
  end

  add_block(css_content)
  self
rescue Errno::ENOENT
  raise IOError, "File not found: #{uri}" if @options[:io_exceptions]

  self
rescue StandardError => e
  raise IOError, "Error loading URI: #{uri} - #{e.message}" if @options[:io_exceptions]

  self
end

#media_indexHash<Symbol, Array<Integer>>, Hash{Symbol => Array<Integer>}

Lazily build and return media_index. Only builds the index when first accessed, not eagerly during parse.

Returns:

  • (Hash<Symbol, Array<Integer>>)

    Cached index mapping media query text to rule IDs

  • (Hash{Symbol => Array<Integer>})

    Hash mapping media types to rule IDs



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/cataract/stylesheet.rb', line 37

def media_index
  # If media_index is empty but we have rules with media_query_id, build it
  if @media_index.empty? && @rules.any? { |r| r.respond_to?(:media_query_id) && r.media_query_id }
    @media_index = {}

    # First, build a reverse lookup: media_query_id => list_id (if in a list)
    mq_id_to_list_id = {}
    @_media_query_lists.each do |list_id, mq_ids|
      mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
    end

    @rules.each do |rule|
      next unless rule.media_query_id

      # Check if this rule's media_query_id is part of a list
      list_id = mq_id_to_list_id[rule.media_query_id]

      if list_id
        # This rule is in a compound media query (e.g., "@media screen, print")
        # Index it under ALL media types in the list
        mq_ids = @_media_query_lists[list_id]
        mq_ids.each do |mq_id|
          mq = @media_queries[mq_id]
          next unless mq

          media_type = mq.type
          @media_index[media_type] ||= []
          @media_index[media_type] << rule.id
        end
      else
        # Single media query - index under its type
        mq = @media_queries[rule.media_query_id]
        next unless mq

        media_type = mq.type
        @media_index[media_type] ||= []
        @media_index[media_type] << rule.id
      end
    end

    # Deduplicate arrays once at the end
    @media_index.each_value(&:uniq!)
  end

  @media_index
end

#mergeObject

Deprecated: Use flatten instead



802
803
804
805
# File 'lib/cataract/stylesheet.rb', line 802

def merge
  warn 'Stylesheet#merge is deprecated, use #flatten instead', uplevel: 1
  flatten
end

#merge!Object

Deprecated: Use flatten! instead



825
826
827
828
# File 'lib/cataract/stylesheet.rb', line 825

def merge!
  warn 'Stylesheet#merge! is deprecated, use #flatten! instead', uplevel: 1
  flatten!
end

#remove_rules!(rules_or_css, media_types: nil) ⇒ self

Remove rules from the stylesheet

Examples:

Remove rules by CSS string

sheet.remove_rules!('.header { }')
sheet.remove_rules!('.header { } .footer { }')

Remove rules from specific media type

sheet.remove_rules!('.header { }', media_types: :screen)

Remove specific rule objects

rules = sheet.select { |r| r.selector =~ /\.btn-/ }
sheet.remove_rules!(rules)

Remove rules with media filtering

sheet.remove_rules!(sheet.with_selector('.header'), media_types: :print)

Parameters:

  • rules_or_css (String, Rule, AtRule, Array<Rule, AtRule>)

    Rules to remove. Can be a CSS string to parse (selectors will be matched), a single Rule/AtRule object, or an array of Rule/AtRule objects.

  • media_types (Symbol, Array<Symbol>, nil) (defaults to: nil)

    Optional media types to filter removal. Only removes rules that match these media types. Pass :all to include base rules.

Returns:

  • (self)

    Returns self for method chaining



623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
# File 'lib/cataract/stylesheet.rb', line 623

def remove_rules!(rules_or_css, media_types: nil)
  # Determine if we're matching by selector (CSS string) or by object identity (rule objects)
  if rules_or_css.is_a?(String)
    # Parse CSS string and extract selectors for matching
    parsed = Stylesheet.parse(rules_or_css)
    selectors_to_remove = parsed.rules.filter_map(&:selector).to_set
    match_by_selector = true
  else
    # Use rule objects directly
    rules_to_remove = rules_or_css.is_a?(Array) ? rules_or_css : [rules_or_css]
    return self if rules_to_remove.empty?

    match_by_selector = false
  end

  # Normalize media_types to array
  filter_media = media_types ? Array(media_types).map(&:to_sym) : nil

  # Find rule IDs to remove
  rule_ids_to_remove = []
  @rules.each_with_index do |rule, rule_id|
    # Check if this rule matches
    matches = if match_by_selector
                # Match by selector for CSS string input
                selectors_to_remove.include?(rule.selector)
              else
                # Match by object equality for rule collection input
                rules_to_remove.any?(rule)
              end
    next unless matches

    # Check media type match if filter is specified
    if filter_media
      rule_media_types = media_index.select { |_media, ids| ids.include?(rule_id) }.keys

      # If rule is not in any media query (base rule), skip unless :all is specified
      if rule_media_types.empty?
        next unless filter_media.include?(:all)
      else
        # Check if rule's media types intersect with filter
        next unless rule_media_types.intersect?(filter_media)
      end
    end

    rule_ids_to_remove << rule_id
  end

  # Remove rules and update media_index (sort in reverse to maintain indices during deletion)
  rule_ids_to_remove.sort.reverse_each do |rule_id|
    @rules.delete_at(rule_id)

    # Remove from media_index and update IDs for rules after this one
    @media_index.each_value do |ids|
      ids.delete(rule_id)
      # Decrement IDs greater than removed ID
      ids.map! { |id| id > rule_id ? id - 1 : id }
    end
  end

  # Clean up empty media_index entries
  @media_index.delete_if { |_media, ids| ids.empty? }

  compact_media_queries!

  # Update rule IDs in remaining rules
  @rules.each_with_index { |rule, new_id| rule.id = new_id }

  clear_memoized_caches

  self
end

#selectorsArray<String>

Get all selectors

Returns:

  • (Array<String>)

    Array of all selectors



399
400
401
# File 'lib/cataract/stylesheet.rb', line 399

def selectors
  @selectors ||= @rules.map(&:selector)
end

#sizeInteger Also known as: length, rules_count

Get number of rules

Returns:

  • (Integer)

    Number of rules



501
502
503
# File 'lib/cataract/stylesheet.rb', line 501

def size
  @rules.length
end

#to_formatted_s(media: :all) ⇒ String

Serialize to formatted CSS string with indentation and newlines.

Converts the stylesheet to a human-readable CSS string with proper indentation. Rules are formatted with each declaration on its own line, and media queries are properly indented. Optionally filters output to specific media queries.

Examples:

Get all CSS formatted

sheet.to_formatted_s
# => "body {\n  color: black;\n}\n@media print {\n  .footer {\n    color: red;\n  }\n}\n"

Filter to specific media type (base rules still included)

sheet.to_formatted_s(media: :print)

Parameters:

  • media (Symbol, Array<Symbol>) (defaults to: :all)

    Optional media filter (default: :all)

    • :all - Output all rules including base rules and all media queries
    • :screen, :print, etc. - Output base rules plus rules from the specified media query
    • [:screen, :print] - Output base rules plus rules from multiple media queries

Returns:

  • (String)

    Formatted CSS string

See Also:



494
495
496
# File 'lib/cataract/stylesheet.rb', line 494

def to_formatted_s(media: :all)
  Cataract.stylesheet_to_formatted_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
end

#to_hHash

Convert to hash

Returns:

  • (Hash)

    Hash representation



743
744
745
746
747
748
# File 'lib/cataract/stylesheet.rb', line 743

def to_h
  {
    rules: @rules,
    charset: @charset
  }
end

#to_s(media: :all) ⇒ String Also known as: to_css

Serialize to CSS string

Converts the stylesheet to a CSS string. Optionally filters output to only include rules from specific media queries.

Important: When filtering to specific media types, base rules (rules not inside any @media block) are always included, since they apply regardless of media context. Only rules from OTHER @media queries are excluded.

Examples:

Get all CSS

sheet.to_s                 # => "body { color: black; } @media print { .footer { color: red; } }"
sheet.to_s(media: :all)    # => "body { color: black; } @media print { .footer { color: red; } }"

Filter to specific media type (base rules still included)

sheet.to_s(media: :print)  # => "body { color: black; } @media print { .footer { color: red; } }"
# Note: base rules like "body { color: black; }" apply during print too, so they're kept

Filter to multiple media types

sheet.to_s(media: [:screen, :print])  # => "@media screen { ... } @media print { ... }"

Parameters:

  • media (Symbol, Array<Symbol>) (defaults to: :all)

    Media type(s) to include (default: :all)

    • :all - Output all rules including base rules and all media queries
    • :screen, :print, etc. - Output base rules plus rules from the specified media query
    • [:screen, :print] - Output base rules plus rules from multiple media queries

Returns:

  • (String)

    CSS string



468
469
470
# File 'lib/cataract/stylesheet.rb', line 468

def to_s(media: :all)
  Cataract.stylesheet_to_s(filter_rules_by_media(media), @charset, @_has_nesting || false, @_selector_lists, @media_queries, @_media_query_lists)
end

#with_at_rule_type(type) ⇒ StylesheetScope

Filter by at-rule type.

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Find all @keyframes

sheet.with_at_rule_type(:keyframes).map(&:selector)

Find all @font-face

sheet.with_at_rule_type(:font_face).to_a

Chain with media filter

sheet.with_media(:screen).with_at_rule_type(:keyframes).to_a

Parameters:

  • type (Symbol)

    At-rule type to match (:keyframes, :font_face, etc.)

Returns:



364
365
366
# File 'lib/cataract/stylesheet.rb', line 364

def with_at_rule_type(type)
  StylesheetScope.new(self, at_rule_type: type)
end

#with_important(property = nil) ⇒ StylesheetScope

Filter to rules with !important declarations.

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Find all rules with any !important

sheet.with_important.map(&:selector)

Find rules with color !important

sheet.with_important('color').to_a

Chain with media filter

sheet.with_media(:screen).with_important.to_a

Parameters:

  • property (String, nil) (defaults to: nil)

    Optional property name to match

Returns:



383
384
385
# File 'lib/cataract/stylesheet.rb', line 383

def with_important(property = nil)
  StylesheetScope.new(self, important: true, important_property: property)
end

#with_media(media) ⇒ StylesheetScope

Filter rules by media query symbol(s).

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Get print media rules

sheet.with_media(:print).each { |rule| puts rule.selector }
sheet.with_media(:print).select(&:selector?).map(&:selector)

Get rules from multiple media queries

sheet.with_media([:screen, :print]).map(&:selector)

Chain filters

sheet.with_media(:print).with_specificity(10..).to_a

Parameters:

  • media (Symbol, Array<Symbol>)

    Media query symbol(s) to filter by

Returns:



264
265
266
# File 'lib/cataract/stylesheet.rb', line 264

def with_media(media)
  StylesheetScope.new(self, media: media)
end

#with_property(property, value = nil, prefix_match: false) ⇒ StylesheetScope

Filter rules by CSS property name and optional value.

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Find all rules with color property

sheet.with_property('color').map(&:selector)

Find rules with position: absolute

sheet.with_property('position', 'absolute').to_a

Find all margin-related properties (margin, margin-top, etc.)

sheet.with_property('margin', prefix_match: true).to_a

Chain with media filter

sheet.with_media(:screen).with_property('z-index').to_a

Parameters:

  • property (String)

    CSS property name to match

  • value (String, nil) (defaults to: nil)

    Optional property value to match

  • prefix_match (Boolean) (defaults to: false)

    Whether to match by prefix (default: false)

Returns:



330
331
332
# File 'lib/cataract/stylesheet.rb', line 330

def with_property(property, value = nil, prefix_match: false)
  StylesheetScope.new(self, property: property, property_value: value, property_prefix_match: prefix_match)
end

#with_selector(selector) ⇒ StylesheetScope

Filter rules by CSS selector.

Returns a chainable StylesheetScope that can be further filtered. Supports both exact string matching and regular expression patterns.

Examples:

Find body rules (exact match)

sheet.with_selector('body').to_a

Find all .btn-* classes (pattern match)

sheet.with_selector(/\.btn-/).map(&:selector)

Find body rules in print media

sheet.with_media(:print).with_selector('body').each { |r| puts r }

Chain multiple filters

sheet.with_selector('.header').with_specificity(10..).to_a

Parameters:

  • selector (String, Regexp)

    CSS selector to match (exact or pattern)

Returns:



306
307
308
# File 'lib/cataract/stylesheet.rb', line 306

def with_selector(selector)
  StylesheetScope.new(self, selector: selector)
end

#with_specificity(specificity) ⇒ StylesheetScope

Filter rules by CSS specificity.

Returns a chainable StylesheetScope that can be further filtered.

Examples:

Get high-specificity rules

sheet.with_specificity(100..).each { |rule| puts rule.selector }

Get exact specificity

sheet.with_specificity(10).map(&:selector)

Chain with media filter

sheet.with_media(:print).with_specificity(10..50).to_a

Parameters:

  • specificity (Integer, Range)

    Specificity value or range

Returns:



283
284
285
# File 'lib/cataract/stylesheet.rb', line 283

def with_specificity(specificity)
  StylesheetScope.new(self, specificity: specificity)
end