Module: StyleCapsule::CssProcessor

Defined in:
lib/style_capsule/css_processor.rb,
sig/style_capsule.rbs

Overview

Shared CSS processing logic for scoping selectors with attribute selectors

Supports two scoping strategies:

  1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
    • Better browser support (all modern browsers)
    • Requires CSS parsing and transformation
  2. CSS nesting (optional): Wraps entire CSS in [data-capsule="..."] { ... }
    • More performant (no parsing needed)
    • Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)

Constant Summary collapse

MAX_CSS_SIZE =

Maximum CSS content size (1MB) to prevent DoS attacks

1_000_000

Class Method Summary collapse

Class Method Details

.scope_selectors(css_string, capsule_id, component_class: nil) ⇒ String

Rewrite CSS selectors to include attribute-based scoping

Transforms:

.section { color: red; }
.heading:hover { opacity: 0.8; }

Into:

[data-capsule="a1b2c3d4"] .section { color: red; }
[data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }

This approach uses attribute selectors (similar to Angular's Emulated View Encapsulation) instead of renaming classes, ensuring styles only apply within scoped components.

Simple approach:

  • Strips CSS comments first to avoid interference
  • Finds selectors before opening braces and prefixes them
  • Handles @media queries (preserves them, scopes inner selectors)
  • Handles :host and :host-context (component-scoped selectors)

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- selector patching walks CSS rules

Parameters:

  • css_string (String)

    Original CSS content

  • capsule_id (String)

    The capsule ID to use in attribute selector

  • component_class (Class, String, nil) (defaults to: nil)

    Optional component class for instrumentation

  • component_class: (Class, String, nil) (defaults to: nil)

Returns:

  • (String)

    CSS with scoped selectors

Raises:

  • (ArgumentError)

    If CSS content exceeds maximum size or capsule_id is invalid



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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/style_capsule/css_processor.rb', line 44

def self.scope_selectors(css_string, capsule_id, component_class: nil)
  return css_string if css_string.nil? || css_string.strip.empty?

  # Validate CSS size to prevent DoS attacks
  if css_string.bytesize > MAX_CSS_SIZE
    raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
  end

  # Validate capsule_id
  validate_capsule_id!(capsule_id)

  # Instrument CSS processing with timing and size metrics
  Instrumentation.instrument_css_processing(
    strategy: :selector_patching,
    component_class: component_class || "Unknown",
    capsule_id: capsule_id,
    css_content: css_string
  ) do
    css = css_string.dup
    capsule_attr = %([data-capsule="#{capsule_id}"])

    # Strip CSS comments so they do not interfere with selector matching.
    # Comments are removed from the output (typical for production CSS).
    css_without_comments = strip_comments(css)

    # Process CSS rule by rule
    # Match: selector(s) { ... }
    # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
    # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
    # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
    css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
      prefix = Regexp.last_match(1)  # Previous closing brace or start
      whitespace = Regexp.last_match(2)  # Whitespace between rules
      selectors_raw = Regexp.last_match(3)  # The selector group
      selectors = selectors_raw.strip  # Stripped for processing
      opening_brace = Regexp.last_match(4)  # The opening brace

      # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
      next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")

      # Skip if already scoped (avoid double-scoping)
      next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")

      # Skip empty selectors
      next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?

      # Split selectors by comma and scope each one
      scoped_selectors = selectors.split(",").map do |selector|
        selector = selector.strip
        next selector if selector.empty?

        # Handle special component-scoped selectors (:host, :host-context)
        if selector.start_with?(":host")
          selector = selector
            .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
            .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
            .gsub(/^:host\b/, capsule_attr)
          selector
        else
          # Add capsule attribute with space before selector for descendant matching
          # This ensures styles apply to elements inside the scoped wrapper
          "#{capsule_attr} #{selector}"
        end
      end.compact.join(", ")

      "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
    end

    css_without_comments
  end
end

.scope_with_nesting(css_string, capsule_id, component_class: nil) ⇒ String

Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })

This approach is more performant as it requires no CSS parsing or transformation. However, it requires CSS nesting support in browsers (Chrome 112+, Firefox 117+, Safari 16.5+).

Transforms:

.section { color: red; }
.heading:hover { opacity: 0.8; }

Into:

[data-capsule="a1b2c3d4"] {
.section { color: red; }
.heading:hover { opacity: 0.8; }
}

Parameters:

  • css_string (String)

    Original CSS content

  • capsule_id (String)

    The capsule ID to use in attribute selector

  • component_class (Class, String, nil) (defaults to: nil)

    Optional component class for instrumentation

  • component_class: (Class, String, nil) (defaults to: nil)

Returns:

  • (String)

    CSS wrapped in nesting selector

Raises:

  • (ArgumentError)

    If CSS content exceeds maximum size or capsule_id is invalid



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/style_capsule/css_processor.rb', line 137

def self.scope_with_nesting(css_string, capsule_id, component_class: nil)
  return css_string if css_string.nil? || css_string.strip.empty?

  # Validate CSS size to prevent DoS attacks
  if css_string.bytesize > MAX_CSS_SIZE
    raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
  end

  # Validate capsule_id
  validate_capsule_id!(capsule_id)

  # Instrument CSS processing with timing and size metrics
  Instrumentation.instrument_css_processing(
    strategy: :nesting,
    component_class: component_class || "Unknown",
    capsule_id: capsule_id,
    css_content: css_string
  ) do
    # Simply wrap the entire CSS in the capsule attribute selector
    # No parsing or transformation needed - much more performant
    capsule_attr = %([data-capsule="#{capsule_id}"])
    "#{capsule_attr} {\n#{css_string}\n}"
  end
end