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:
- Selector patching (default): Adds [data-capsule="..."] prefix to each selector
- Better browser support (all modern browsers)
- Requires CSS parsing and transformation
- 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
-
.scope_selectors(css_string, capsule_id, component_class: nil) ⇒ String
Rewrite CSS selectors to include attribute-based scoping.
-
.scope_with_nesting(css_string, capsule_id, component_class: nil) ⇒ String
Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... }).
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
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; }
}
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 |