style_capsule 1.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.
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Builds CSS files from StyleCapsule components
5
+ #
6
+ # This class extracts the logic from the rake task so it can be tested independently.
7
+ # The rake task delegates to this class.
8
+ class ComponentBuilder
9
+ class << self
10
+ # Check if Phlex is available
11
+ # This method can be stubbed in tests to test fallback paths
12
+ def phlex_available?
13
+ !!defined?(Phlex::HTML)
14
+ end
15
+
16
+ # Check if ViewComponent is available
17
+ # This method can be stubbed in tests to test fallback paths
18
+ def view_component_available?
19
+ !!defined?(ViewComponent::Base)
20
+ end
21
+
22
+ # Find all Phlex components that use StyleCapsule
23
+ #
24
+ # Uses ClassRegistry to find registered components (Rails-friendly, avoids expensive ObjectSpace scanning).
25
+ # Classes are automatically registered when they include StyleCapsule::Component.
26
+ #
27
+ # @return [Array<Class>] Array of component classes
28
+ def find_phlex_components
29
+ return [] unless phlex_available?
30
+
31
+ components = []
32
+
33
+ # Use the registry (Rails-friendly, avoids expensive ObjectSpace scanning)
34
+ ClassRegistry.each do |klass|
35
+ if klass < Phlex::HTML && klass.included_modules.include?(StyleCapsule::Component)
36
+ components << klass
37
+ end
38
+ rescue
39
+ # Skip classes that cause errors (inheritance checks, etc.)
40
+ next
41
+ end
42
+
43
+ components
44
+ end
45
+
46
+ # Find all ViewComponent components that use StyleCapsule
47
+ #
48
+ # Uses ClassRegistry to find registered components (Rails-friendly, avoids expensive ObjectSpace scanning).
49
+ # Classes are automatically registered when they include StyleCapsule::ViewComponent.
50
+ #
51
+ # @return [Array<Class>] Array of component classes
52
+ def find_view_components
53
+ return [] unless view_component_available?
54
+
55
+ components = []
56
+
57
+ begin
58
+ # Use the registry (Rails-friendly, avoids expensive ObjectSpace scanning)
59
+ ClassRegistry.each do |klass|
60
+ if klass < ViewComponent::Base && klass.included_modules.include?(StyleCapsule::ViewComponent)
61
+ components << klass
62
+ end
63
+ rescue
64
+ # Skip classes that cause errors (inheritance checks, etc.)
65
+ next
66
+ end
67
+ rescue
68
+ # ViewComponent may have loading issues (e.g., version compatibility)
69
+ # Silently skip ViewComponent components if there's an error
70
+ # This allows the rake task to continue with Phlex components
71
+ end
72
+
73
+ components
74
+ end
75
+
76
+ # Collect all component classes that use StyleCapsule
77
+ #
78
+ # @return [Array<Class>] Array of component classes
79
+ def collect_components
80
+ find_phlex_components + find_view_components
81
+ end
82
+
83
+ # Build CSS file for a single component
84
+ #
85
+ # @param component_class [Class] Component class to build
86
+ # @param output_proc [Proc, nil] Optional proc to call with output messages
87
+ # @return [String, nil] Generated file path or nil if skipped
88
+ def build_component(component_class, output_proc: nil)
89
+ return nil unless component_class.inline_cache_strategy == :file
90
+ # Check for class method component_styles (required for file caching)
91
+ return nil unless component_class.respond_to?(:component_styles, false)
92
+
93
+ begin
94
+ # Use class method component_styles for file caching
95
+ css_content = component_class.component_styles
96
+ return nil if css_content.nil? || css_content.to_s.strip.empty?
97
+
98
+ # Create a temporary instance to get capsule
99
+ # Some components might require arguments, so we catch errors
100
+ instance = component_class.new
101
+ capsule_id = instance.component_capsule
102
+ scoped_css = instance.send(:scope_css, css_content)
103
+
104
+ # Write CSS file
105
+ file_path = CssFileWriter.write_css(
106
+ css_content: scoped_css,
107
+ component_class: component_class,
108
+ capsule_id: capsule_id
109
+ )
110
+
111
+ output_proc&.call("Generated: #{file_path}") if file_path
112
+ file_path
113
+ rescue ArgumentError, NoMethodError => e
114
+ # Component requires arguments or has dependencies - skip it
115
+ output_proc&.call("Skipped #{component_class.name}: #{e.message}")
116
+ nil
117
+ end
118
+ end
119
+
120
+ # Build CSS files for all components
121
+ #
122
+ # @param output_proc [Proc, nil] Optional proc to call with output messages
123
+ # @return [Integer] Number of files generated
124
+ def build_all(output_proc: nil)
125
+ require "style_capsule/css_file_writer"
126
+
127
+ # Ensure output directory exists
128
+ CssFileWriter.ensure_output_directory
129
+
130
+ # Collect all component classes that use StyleCapsule
131
+ component_classes = collect_components
132
+
133
+ # Generate CSS files for each component
134
+ generated_count = 0
135
+ component_classes.each do |component_class|
136
+ file_path = build_component(component_class, output_proc: output_proc)
137
+ generated_count += 1 if file_path
138
+ end
139
+
140
+ output_proc&.call("StyleCapsule CSS files built successfully")
141
+ generated_count
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Shared module for component styles support (used by both Component and ViewComponent)
5
+ #
6
+ # Provides unified support for both instance and class method component_styles:
7
+ # - Instance method: `def component_styles` - dynamic rendering, supports all cache strategies except :file
8
+ # - Class method: `def self.component_styles` - static rendering, supports all cache strategies including :file
9
+ module ComponentStylesSupport
10
+ # Check if component defines styles (instance or class method)
11
+ #
12
+ # @return [Boolean]
13
+ def component_styles?
14
+ instance_styles? || class_styles?
15
+ end
16
+
17
+ # Resolve component styles (from instance or class method)
18
+ #
19
+ # @return [String, nil] CSS content or nil if no styles defined
20
+ def component_styles_content
21
+ # Prefer instance method for dynamic rendering
22
+ if instance_styles?
23
+ component_styles
24
+ elsif class_styles?
25
+ self.class.component_styles
26
+ end
27
+ end
28
+
29
+ # Check if component uses class method styles exclusively (required for file caching)
30
+ #
31
+ # Returns true only if class styles are defined and instance styles are not.
32
+ #
33
+ # @return [Boolean]
34
+ def class_styles_only?
35
+ class_styles? && !instance_styles?
36
+ end
37
+
38
+ # Check if file caching is allowed for this component
39
+ #
40
+ # File caching is only allowed for class method component_styles
41
+ #
42
+ # @return [Boolean]
43
+ def file_caching_allowed?
44
+ cache_strategy = self.class.inline_cache_strategy
45
+ return false unless cache_strategy == :file
46
+ class_styles_only?
47
+ end
48
+
49
+ private
50
+
51
+ # Check if component defines instance method styles
52
+ #
53
+ # @return [Boolean]
54
+ def instance_styles?
55
+ return false unless respond_to?(:component_styles, true)
56
+ styles = component_styles
57
+ styles && !styles.to_s.strip.empty?
58
+ end
59
+
60
+ # Check if component defines class method styles
61
+ #
62
+ # @return [Boolean]
63
+ def class_styles?
64
+ return false unless self.class.respond_to?(:component_styles, false)
65
+ begin
66
+ styles = self.class.component_styles
67
+ styles && !styles.to_s.strip.empty?
68
+ rescue NoMethodError
69
+ false
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest/sha1"
5
+
6
+ module StyleCapsule
7
+ # Writes inline CSS to files for HTTP caching
8
+ #
9
+ # This allows inline CSS to be cached by browsers and CDNs, improving performance.
10
+ # Files are written to a configurable output directory and can be precompiled
11
+ # via Rails asset pipeline.
12
+ #
13
+ # @example Configuration
14
+ # StyleCapsule::CssFileWriter.configure(
15
+ # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
16
+ # filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" }
17
+ # )
18
+ #
19
+ # @example Usage
20
+ # file_path = StyleCapsule::CssFileWriter.write_css(
21
+ # css_content: ".section { color: red; }",
22
+ # component_class: MyComponent,
23
+ # capsule_id: "abc123"
24
+ # )
25
+ # # => "capsules/capsule-abc123"
26
+ class CssFileWriter
27
+ # Default output directory for CSS files (relative to Rails root)
28
+ DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
29
+
30
+ class << self
31
+ attr_accessor :output_dir, :filename_pattern, :enabled
32
+
33
+ # Configure CSS file writer
34
+ #
35
+ # @param output_dir [String, Pathname] Directory to write CSS files (relative to Rails root or absolute)
36
+ # Default: `StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR`
37
+ # @param filename_pattern [Proc, nil] Proc to generate filename
38
+ # Receives: (component_class, capsule_id) and should return filename string
39
+ # Default: `"capsule-#{capsule_id}.css"` (capsule_id is unique and deterministic)
40
+ # @param enabled [Boolean] Whether file writing is enabled (default: true)
41
+ # @example
42
+ # StyleCapsule::CssFileWriter.configure(
43
+ # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
44
+ # filename_pattern: ->(klass, capsule) { "capsule-#{capsule}.css" }
45
+ # )
46
+ # @example Custom pattern with component name
47
+ # StyleCapsule::CssFileWriter.configure(
48
+ # filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
49
+ # )
50
+ def configure(output_dir: nil, filename_pattern: nil, enabled: true)
51
+ @enabled = enabled
52
+
53
+ @output_dir = if output_dir
54
+ output_dir.is_a?(Pathname) ? output_dir : Pathname.new(output_dir.to_s)
55
+ elsif rails_available?
56
+ Rails.root.join(DEFAULT_OUTPUT_DIR)
57
+ else
58
+ Pathname.new(DEFAULT_OUTPUT_DIR)
59
+ end
60
+
61
+ @filename_pattern = filename_pattern || default_filename_pattern
62
+ end
63
+
64
+ # Write CSS content to file
65
+ #
66
+ # @param css_content [String] CSS content to write
67
+ # @param component_class [Class] Component class that generated the CSS
68
+ # @param capsule_id [String] Capsule ID for the component
69
+ # @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled
70
+ def write_css(css_content:, component_class:, capsule_id:)
71
+ return nil unless enabled?
72
+
73
+ ensure_output_directory
74
+
75
+ filename = generate_filename(component_class, capsule_id)
76
+ file_path = output_directory.join(filename)
77
+
78
+ # Write CSS to file with explicit UTF-8 encoding
79
+ File.write(file_path, css_content, encoding: "UTF-8")
80
+
81
+ # Return relative path for stylesheet_link_tag
82
+ # Path should be relative to app/assets
83
+ # Handle case where output directory is not under rails_assets_root (e.g., in tests)
84
+ begin
85
+ file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
86
+ rescue ArgumentError
87
+ # If paths don't share a common prefix (e.g., in tests), return just the filename
88
+ filename.gsub(/\.css$/, "")
89
+ end
90
+ end
91
+
92
+ # Check if file exists for given component and capsule
93
+ #
94
+ # @param component_class [Class] Component class
95
+ # @param capsule_id [String] Capsule ID
96
+ # @return [Boolean]
97
+ def file_exists?(component_class:, capsule_id:)
98
+ return false unless enabled?
99
+
100
+ filename = generate_filename(component_class, capsule_id)
101
+ file_path = output_directory.join(filename)
102
+ File.exist?(file_path)
103
+ end
104
+
105
+ # Get file path for given component and capsule
106
+ #
107
+ # @param component_class [Class] Component class
108
+ # @param capsule_id [String] Capsule ID
109
+ # @return [String, nil] Relative file path or nil if disabled
110
+ def file_path_for(component_class:, capsule_id:)
111
+ return nil unless enabled?
112
+
113
+ filename = generate_filename(component_class, capsule_id)
114
+ file_path = output_directory.join(filename)
115
+
116
+ return nil unless File.exist?(file_path)
117
+
118
+ # Return relative path for stylesheet_link_tag
119
+ # Handle case where output directory is not under rails_assets_root (e.g., in tests)
120
+ begin
121
+ file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
122
+ rescue ArgumentError
123
+ # If paths don't share a common prefix (e.g., in tests), return just the filename
124
+ filename.gsub(/\.css$/, "")
125
+ end
126
+ end
127
+
128
+ # Ensure output directory exists
129
+ #
130
+ # @return [void]
131
+ def ensure_output_directory
132
+ return unless enabled?
133
+
134
+ dir = output_directory
135
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
136
+ end
137
+
138
+ # Clear all generated CSS files
139
+ #
140
+ # @return [void]
141
+ def clear_files
142
+ return unless enabled?
143
+
144
+ dir = output_directory
145
+ return unless Dir.exist?(dir)
146
+
147
+ Dir.glob(dir.join("*.css")).each { |file| File.delete(file) }
148
+ end
149
+
150
+ # Check if file writing is enabled
151
+ #
152
+ # @return [Boolean]
153
+ def enabled?
154
+ @enabled != false
155
+ end
156
+
157
+ # Check if Rails is available
158
+ # This method can be stubbed in tests to test fallback paths
159
+ def rails_available?
160
+ defined?(Rails) && Rails.respond_to?(:root) && Rails.root
161
+ end
162
+
163
+ private
164
+
165
+ # Get output directory (with default)
166
+ def output_directory
167
+ @output_dir ||= if rails_available?
168
+ Rails.root.join(DEFAULT_OUTPUT_DIR)
169
+ else
170
+ Pathname.new(DEFAULT_OUTPUT_DIR)
171
+ end
172
+ end
173
+
174
+ # Generate filename using pattern
175
+ #
176
+ # @param component_class [Class] Component class
177
+ # @param capsule_id [String] Capsule ID
178
+ # @return [String] Validated filename
179
+ # @raise [SecurityError] If filename contains path traversal or invalid characters
180
+ def generate_filename(component_class, capsule_id)
181
+ pattern_result = filename_pattern.call(component_class, capsule_id)
182
+ # Ensure .css extension
183
+ filename = pattern_result.end_with?(".css") ? pattern_result : "#{pattern_result}.css"
184
+
185
+ # Validate filename to prevent path traversal attacks
186
+ validate_filename!(filename)
187
+
188
+ filename
189
+ end
190
+
191
+ # Default filename pattern
192
+ #
193
+ # Uses only the capsule_id since it's unique and deterministic.
194
+ # The capsule_id is generated from the component class name using SHA1,
195
+ # ensuring uniqueness while keeping filenames concise and not exposing
196
+ # internal component structure.
197
+ #
198
+ # @return [Proc] Proc that generates filename from (component_class, capsule_id)
199
+ def default_filename_pattern
200
+ ->(component_class, capsule_id) do
201
+ # Validate capsule_id is safe (alphanumeric, hyphens, underscores only)
202
+ # This ensures the filename is safe even if capsule_id generation changes
203
+ safe_capsule_id = capsule_id.to_s.gsub(/[^a-zA-Z0-9_-]/, "")
204
+
205
+ if safe_capsule_id.empty?
206
+ raise ArgumentError, "Invalid capsule_id: must contain at least one alphanumeric character"
207
+ end
208
+
209
+ "capsule-#{safe_capsule_id}.css"
210
+ end
211
+ end
212
+
213
+ # Get Rails assets root (app/assets)
214
+ def rails_assets_root
215
+ if rails_available?
216
+ Rails.root.join("app/assets")
217
+ else
218
+ Pathname.new("app/assets")
219
+ end
220
+ end
221
+
222
+ # Validate filename to prevent path traversal and other security issues
223
+ #
224
+ # @param filename [String] Filename to validate
225
+ # @raise [SecurityError] If filename is invalid
226
+ def validate_filename!(filename)
227
+ # Reject path traversal attempts
228
+ if filename.include?("..") || filename.include?("/") || filename.include?("\\")
229
+ raise SecurityError, "Invalid filename: path traversal detected in '#{filename}'"
230
+ end
231
+
232
+ # Reject null bytes
233
+ if filename.include?("\0")
234
+ raise SecurityError, "Invalid filename: null byte detected"
235
+ end
236
+
237
+ # Ensure filename is reasonable length (filesystem limit is typically 255)
238
+ if filename.length > 255
239
+ raise SecurityError, "Invalid filename: too long (max 255 characters, got #{filename.length})"
240
+ end
241
+
242
+ # Ensure filename contains only safe characters (alphanumeric, dots, hyphens, underscores)
243
+ # Must end with .css extension
244
+ unless filename.match?(/\A[a-zA-Z0-9._-]+\.css\z/)
245
+ raise SecurityError, "Invalid filename: contains unsafe characters (only alphanumeric, dots, hyphens, underscores allowed, must end with .css)"
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Shared CSS processing logic for scoping selectors with attribute selectors
5
+ #
6
+ # Supports two scoping strategies:
7
+ # 1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
8
+ # - Better browser support (all modern browsers)
9
+ # - Requires CSS parsing and transformation
10
+ # 2. CSS nesting (optional): Wraps entire CSS in [data-capsule="..."] { ... }
11
+ # - More performant (no parsing needed)
12
+ # - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
13
+ module CssProcessor
14
+ # Maximum CSS content size (1MB) to prevent DoS attacks
15
+ MAX_CSS_SIZE = 1_000_000
16
+
17
+ # Rewrite CSS selectors to include attribute-based scoping
18
+ #
19
+ # Transforms:
20
+ # .section { color: red; }
21
+ # .heading:hover { opacity: 0.8; }
22
+ #
23
+ # Into:
24
+ # [data-capsule="a1b2c3d4"] .section { color: red; }
25
+ # [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
26
+ #
27
+ # This approach uses attribute selectors (similar to Angular's Emulated View Encapsulation)
28
+ # instead of renaming classes, ensuring styles only apply within scoped components.
29
+ #
30
+ # Simple approach:
31
+ # - Strips CSS comments first to avoid interference
32
+ # - Finds selectors before opening braces and prefixes them
33
+ # - Handles @media queries (preserves them, scopes inner selectors)
34
+ # - Handles :host and :host-context (component-scoped selectors)
35
+ #
36
+ # @param css_string [String] Original CSS content
37
+ # @param capsule_id [String] The capsule ID to use in attribute selector
38
+ # @return [String] CSS with scoped selectors
39
+ # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
40
+ def self.scope_selectors(css_string, capsule_id)
41
+ return css_string if css_string.nil? || css_string.strip.empty?
42
+
43
+ # Validate CSS size to prevent DoS attacks
44
+ if css_string.bytesize > MAX_CSS_SIZE
45
+ raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
46
+ end
47
+
48
+ # Validate capsule_id
49
+ validate_capsule_id!(capsule_id)
50
+
51
+ css = css_string.dup
52
+ capsule_attr = %([data-capsule="#{capsule_id}"])
53
+
54
+ # Strip CSS comments to avoid interference with selector matching
55
+ # Simple approach: remove /* ... */ comments (including multi-line)
56
+ css_without_comments = strip_comments(css)
57
+
58
+ # Process CSS rule by rule
59
+ # Match: selector(s) { ... }
60
+ # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
61
+ # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
62
+ # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
63
+ css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
64
+ prefix = Regexp.last_match(1) # Previous closing brace or start
65
+ whitespace = Regexp.last_match(2) # Whitespace between rules
66
+ selectors_raw = Regexp.last_match(3) # The selector group
67
+ selectors = selectors_raw.strip # Stripped for processing
68
+ opening_brace = Regexp.last_match(4) # The opening brace
69
+
70
+ # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
71
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
72
+
73
+ # Skip if already scoped (avoid double-scoping)
74
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
75
+
76
+ # Skip empty selectors
77
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
78
+
79
+ # Split selectors by comma and scope each one
80
+ scoped_selectors = selectors.split(",").map do |selector|
81
+ selector = selector.strip
82
+ next selector if selector.empty?
83
+
84
+ # Handle special component-scoped selectors (:host, :host-context)
85
+ if selector.start_with?(":host")
86
+ selector = selector
87
+ .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
88
+ .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
89
+ .gsub(/^:host\b/, capsule_attr)
90
+ selector
91
+ else
92
+ # Add capsule attribute with space before selector for descendant matching
93
+ # This ensures styles apply to elements inside the scoped wrapper
94
+ "#{capsule_attr} #{selector}"
95
+ end
96
+ end.compact.join(", ")
97
+
98
+ "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
99
+ end
100
+
101
+ # Restore comments in their original positions
102
+ # Since we stripped comments, we need to put them back
103
+ # For simplicity, we'll just return the processed CSS without comments
104
+ # (comments are typically removed in production CSS anyway)
105
+ css_without_comments
106
+ end
107
+
108
+ # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
109
+ #
110
+ # This approach is more performant as it requires no CSS parsing or transformation.
111
+ # However, it requires CSS nesting support in browsers (Chrome 112+, Firefox 117+, Safari 16.5+).
112
+ #
113
+ # Transforms:
114
+ # .section { color: red; }
115
+ # .heading:hover { opacity: 0.8; }
116
+ #
117
+ # Into:
118
+ # [data-capsule="a1b2c3d4"] {
119
+ # .section { color: red; }
120
+ # .heading:hover { opacity: 0.8; }
121
+ # }
122
+ #
123
+ # @param css_string [String] Original CSS content
124
+ # @param capsule_id [String] The capsule ID to use in attribute selector
125
+ # @return [String] CSS wrapped in nesting selector
126
+ # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
127
+ def self.scope_with_nesting(css_string, capsule_id)
128
+ return css_string if css_string.nil? || css_string.strip.empty?
129
+
130
+ # Validate CSS size to prevent DoS attacks
131
+ if css_string.bytesize > MAX_CSS_SIZE
132
+ raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
133
+ end
134
+
135
+ # Validate capsule_id
136
+ validate_capsule_id!(capsule_id)
137
+
138
+ # Simply wrap the entire CSS in the capsule attribute selector
139
+ # No parsing or transformation needed - much more performant
140
+ capsule_attr = %([data-capsule="#{capsule_id}"])
141
+ "#{capsule_attr} {\n#{css_string}\n}"
142
+ end
143
+
144
+ # Strip CSS comments (/* ... */) from the string
145
+ #
146
+ # @param css [String] CSS content with comments
147
+ # @return [String] CSS content without comments
148
+ def self.strip_comments(css)
149
+ # Remove /* ... */ comments (including multi-line)
150
+ # Use non-greedy match to handle multiple comments
151
+ css.gsub(/\/\*.*?\*\//m, "")
152
+ end
153
+
154
+ # Validate capsule ID to prevent injection attacks
155
+ #
156
+ # @param capsule_id [String] Capsule ID to validate
157
+ # @raise [ArgumentError] If capsule_id is invalid
158
+ def self.validate_capsule_id!(capsule_id)
159
+ unless capsule_id.is_a?(String)
160
+ raise ArgumentError, "capsule_id must be a String (got #{capsule_id.class})"
161
+ end
162
+
163
+ # Must not be empty (check first for clearer error message)
164
+ if capsule_id.empty?
165
+ raise ArgumentError, "capsule_id cannot be empty"
166
+ end
167
+
168
+ # Capsule ID should only contain alphanumeric characters, hyphens, and underscores
169
+ # This prevents injection into HTML attributes
170
+ unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
171
+ raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
172
+ end
173
+
174
+ # Reasonable length limit
175
+ if capsule_id.length > 100
176
+ raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
177
+ end
178
+ end
179
+
180
+ private_class_method :strip_comments, :validate_capsule_id!
181
+ end
182
+ end