style_capsule 2.0.1 → 2.0.3

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "digest/sha1"
5
+ require "tmpdir"
5
6
  require_relative "instrumentation"
6
7
 
7
8
  module StyleCapsule
@@ -12,7 +13,7 @@ module StyleCapsule
12
13
  # via Rails asset pipeline.
13
14
  #
14
15
  # In production environments where the app directory is read-only (e.g., Docker containers),
15
- # this class automatically falls back to writing files to /tmp/style_capsule when the
16
+ # this class automatically falls back to writing files under Dir.tmpdir when the
16
17
  # default location is not writable. When using the fallback directory, write_css returns
17
18
  # nil, causing StylesheetRegistry to fall back to inline CSS (keeping the UI functional).
18
19
  #
@@ -32,7 +33,7 @@ module StyleCapsule
32
33
  # StyleCapsule::CssFileWriter.configure(
33
34
  # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
34
35
  # filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" },
35
- # fallback_dir: "/tmp/style_capsule" # Optional, defaults to /tmp/style_capsule
36
+ # fallback_dir: StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR
36
37
  # )
37
38
  #
38
39
  # @example Usage
@@ -74,7 +75,7 @@ module StyleCapsule
74
75
  # Default output directory for CSS files (relative to Rails root)
75
76
  DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
76
77
  # Fallback directory for when default location is read-only (absolute path)
77
- FALLBACK_OUTPUT_DIR = "/tmp/style_capsule"
78
+ FALLBACK_OUTPUT_DIR = File.join(Dir.tmpdir, "style_capsule-#{Process.uid}")
78
79
  # Positive cache for resolved asset-relative paths (avoids repeated File.exist? on hot path)
79
80
  FILE_PATH_CACHE_MAX = 512
80
81
 
@@ -90,7 +91,7 @@ module StyleCapsule
90
91
  # Default: `"capsule-#{capsule_id}.css"` (capsule_id is unique and deterministic)
91
92
  # @param enabled [Boolean] Whether file writing is enabled (default: true)
92
93
  # @param fallback_dir [String, Pathname, nil] Fallback directory when default location is read-only
93
- # Default: `StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR` (/tmp/style_capsule)
94
+ # Default: `StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR`
94
95
  # @example
95
96
  # StyleCapsule::CssFileWriter.configure(
96
97
  # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
@@ -7,15 +7,25 @@ module StyleCapsule
7
7
  #
8
8
  # Supports two scoping strategies:
9
9
  # 1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
10
- # - Better browser support (all modern browsers)
10
+ # - Wider browser support (all modern browsers)
11
11
  # - Requires CSS parsing and transformation
12
- # 2. CSS nesting (optional): Wraps entire CSS in [data-capsule="..."] { ... }
13
- # - More performant (no parsing needed)
12
+ # 2. CSS nesting (optional): Wraps style rules in [data-capsule="..."] { ... }
13
+ # - Avoids per-selector rewrite
14
14
  # - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
15
+ # - Hoists non-nestable at-rules (@keyframes, @font-face, @import, ...) to top level
16
+ # rubocop:disable Metrics/ModuleLength -- processor owns patching, nesting hoist, and validation
15
17
  module CssProcessor
16
18
  # Maximum CSS content size (1MB) to prevent DoS attacks
17
19
  MAX_CSS_SIZE = 1_000_000
18
20
 
21
+ # At-rules whose inner selectors must not receive attribute scoping
22
+ NON_SCOPING_AT_RULES = %w[keyframes font-face property].freeze
23
+
24
+ # At-rules that must stay at the top level under CSS nesting
25
+ HOISTABLE_AT_RULES = %w[
26
+ keyframes font-face import charset namespace property
27
+ ].freeze
28
+
19
29
  # Rewrite CSS selectors to include attribute-based scoping
20
30
  #
21
31
  # Transforms:
@@ -26,98 +36,40 @@ module StyleCapsule
26
36
  # [data-capsule="a1b2c3d4"] .section { color: red; }
27
37
  # [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
28
38
  #
29
- # This approach uses attribute selectors (similar to Angular's Emulated View Encapsulation)
30
- # instead of renaming classes, ensuring styles only apply within scoped components.
31
- #
32
- # Simple approach:
33
- # - Strips CSS comments first to avoid interference
34
- # - Finds selectors before opening braces and prefixes them
35
- # - Handles @media queries (preserves them, scopes inner selectors)
36
- # - Handles :host and :host-context (component-scoped selectors)
39
+ # Uses attribute selectors instead of renaming classes, so styles apply only
40
+ # within scoped components. Leaves @keyframes / @font-face / @property steps alone.
37
41
  #
38
42
  # @param css_string [String] Original CSS content
39
43
  # @param capsule_id [String] The capsule ID to use in attribute selector
40
44
  # @param component_class [Class, String, nil] Optional component class for instrumentation
41
45
  # @return [String] CSS with scoped selectors
42
46
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
43
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- selector patching walks CSS rules
44
47
  def self.scope_selectors(css_string, capsule_id, component_class: nil)
45
48
  return css_string if css_string.nil? || css_string.strip.empty?
46
49
 
47
- # Validate CSS size to prevent DoS attacks
48
50
  if css_string.bytesize > MAX_CSS_SIZE
49
51
  raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
50
52
  end
51
53
 
52
- # Validate capsule_id
54
+ reject_style_element_breakout!(css_string)
53
55
  validate_capsule_id!(capsule_id)
54
56
 
55
- # Instrument CSS processing with timing and size metrics
56
57
  Instrumentation.instrument_css_processing(
57
58
  strategy: :selector_patching,
58
59
  component_class: component_class || "Unknown",
59
60
  capsule_id: capsule_id,
60
61
  css_content: css_string
61
62
  ) do
62
- css = css_string.dup
63
63
  capsule_attr = %([data-capsule="#{capsule_id}"])
64
-
65
- # Strip CSS comments so they do not interfere with selector matching.
66
- # Comments are removed from the output (typical for production CSS).
67
- css_without_comments = strip_comments(css)
68
-
69
- # Process CSS rule by rule
70
- # Match: selector(s) { ... }
71
- # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
72
- # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
73
- # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
74
- css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
75
- prefix = Regexp.last_match(1) # Previous closing brace or start
76
- whitespace = Regexp.last_match(2) # Whitespace between rules
77
- selectors_raw = Regexp.last_match(3) # The selector group
78
- selectors = selectors_raw.strip # Stripped for processing
79
- opening_brace = Regexp.last_match(4) # The opening brace
80
-
81
- # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
82
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
83
-
84
- # Skip if already scoped (avoid double-scoping)
85
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
86
-
87
- # Skip empty selectors
88
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
89
-
90
- # Split selectors by comma and scope each one
91
- scoped_selectors = selectors.split(",").map do |selector|
92
- selector = selector.strip
93
- next selector if selector.empty?
94
-
95
- # Handle special component-scoped selectors (:host, :host-context)
96
- if selector.start_with?(":host")
97
- selector = selector
98
- .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
99
- .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
100
- .gsub(/^:host\b/, capsule_attr)
101
- selector
102
- else
103
- # Add capsule attribute with space before selector for descendant matching
104
- # This ensures styles apply to elements inside the scoped wrapper
105
- "#{capsule_attr} #{selector}"
106
- end
107
- end.compact.join(", ")
108
-
109
- "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
110
- end
111
-
112
- css_without_comments
64
+ patch_selectors(strip_comments(css_string.dup), capsule_attr)
113
65
  end
114
66
  end
115
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
116
67
 
117
- # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
68
+ # Scope CSS using CSS nesting (wraps nestable rules in [data-capsule] { ... })
118
69
  #
119
- # This approach is more performant as it requires no CSS parsing or transformation.
120
- # However, it requires CSS nesting support in browsers (Chrome 112+, Firefox 117+, Safari 16.5+).
70
+ # Avoids per-selector rewrite. Requires CSS nesting support in browsers
71
+ # (Chrome 112+, Firefox 117+, Safari 16.5+). Non-nestable at-rules are written
72
+ # before the wrapper so @keyframes and @font-face stay valid.
121
73
  #
122
74
  # Transforms:
123
75
  # .section { color: red; }
@@ -132,43 +84,60 @@ module StyleCapsule
132
84
  # @param css_string [String] Original CSS content
133
85
  # @param capsule_id [String] The capsule ID to use in attribute selector
134
86
  # @param component_class [Class, String, nil] Optional component class for instrumentation
135
- # @return [String] CSS wrapped in nesting selector
87
+ # @return [String] CSS with nesting wrapper and hoisted at-rules
136
88
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
89
+ # rubocop:disable Metrics/AbcSize -- size check, breakout, instrument, then hoist and wrap
137
90
  def self.scope_with_nesting(css_string, capsule_id, component_class: nil)
138
91
  return css_string if css_string.nil? || css_string.strip.empty?
139
92
 
140
- # Validate CSS size to prevent DoS attacks
141
93
  if css_string.bytesize > MAX_CSS_SIZE
142
94
  raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
143
95
  end
144
96
 
145
- # Validate capsule_id
97
+ reject_style_element_breakout!(css_string)
146
98
  validate_capsule_id!(capsule_id)
147
99
 
148
- # Instrument CSS processing with timing and size metrics
149
100
  Instrumentation.instrument_css_processing(
150
101
  strategy: :nesting,
151
102
  component_class: component_class || "Unknown",
152
103
  capsule_id: capsule_id,
153
104
  css_content: css_string
154
105
  ) do
155
- # Simply wrap the entire CSS in the capsule attribute selector
156
- # No parsing or transformation needed - much more performant
157
106
  capsule_attr = %([data-capsule="#{capsule_id}"])
158
- "#{capsule_attr} {\n#{css_string}\n}"
107
+ hoisted, nestable = hoist_top_level_at_rules(css_string)
108
+ parts = []
109
+ parts << hoisted unless hoisted.empty?
110
+ parts << "#{capsule_attr} {\n#{nestable}\n}" unless nestable.strip.empty?
111
+ parts.join("\n")
159
112
  end
160
113
  end
114
+ # rubocop:enable Metrics/AbcSize
161
115
 
162
116
  # Strip CSS comments (/* ... */) from the string
163
117
  #
164
118
  # @param css [String] CSS content with comments
165
119
  # @return [String] CSS content without comments
166
120
  def self.strip_comments(css)
167
- # Remove /* ... */ comments (including multi-line)
168
- # Use non-greedy match to handle multiple comments
169
121
  css.gsub(/\/\*.*?\*\//m, "")
170
122
  end
171
123
 
124
+ # Reject CSS that would close an enclosing HTML style element
125
+ #
126
+ # HTML parses style-element content until the first case-insensitive closer,
127
+ # including sequences inside CSS comments or string literals.
128
+ #
129
+ # @param css_string [String, nil] CSS that will be placed inside a style tag
130
+ # @raise [ArgumentError] If the string contains a style element closer
131
+ def self.reject_style_element_breakout!(css_string)
132
+ # Core path must not call ActiveSupport String#blank?
133
+ # rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
134
+ return if css_string.nil? || css_string.empty?
135
+ # rubocop:enable Rails/Blank
136
+ return unless css_string.match?(%r{</style}i)
137
+
138
+ raise ArgumentError, "CSS content must not contain a style element closer"
139
+ end
140
+
172
141
  # Validate capsule ID to prevent injection attacks
173
142
  #
174
143
  # @param capsule_id [String] Capsule ID to validate
@@ -178,23 +147,191 @@ module StyleCapsule
178
147
  raise ArgumentError, "capsule_id must be a String (got #{capsule_id.class})"
179
148
  end
180
149
 
181
- # Must not be empty (check first for clearer error message)
182
150
  if capsule_id.empty?
183
151
  raise ArgumentError, "capsule_id cannot be empty"
184
152
  end
185
153
 
186
- # Capsule ID should only contain alphanumeric characters, hyphens, and underscores
187
- # This prevents injection into HTML attributes
188
154
  unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
189
155
  raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
190
156
  end
191
157
 
192
- # Reasonable length limit
193
158
  if capsule_id.length > 100
194
159
  raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
195
160
  end
196
161
  end
197
162
 
198
- private_class_method :strip_comments, :validate_capsule_id!
163
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- walks braces and at-rules in one pass
164
+ def self.patch_selectors(css, capsule_attr)
165
+ output = +""
166
+ index = 0
167
+ length = css.length
168
+ brace_depth = 0
169
+ suppress_below_depth = nil
170
+
171
+ while index < length
172
+ while index < length && css[index].match?(/\s/)
173
+ output << css[index]
174
+ index += 1
175
+ end
176
+ break if index >= length
177
+
178
+ if css[index] == "}"
179
+ output << "}"
180
+ index += 1
181
+ brace_depth -= 1
182
+ if !suppress_below_depth.nil? && brace_depth <= suppress_below_depth
183
+ suppress_below_depth = nil
184
+ end
185
+ next
186
+ end
187
+
188
+ selector_start = index
189
+ while index < length && css[index] != "{" && css[index] != "}"
190
+ index += 1
191
+ end
192
+
193
+ selectors_raw = css[selector_start...index]
194
+ if index >= length || css[index] == "}"
195
+ output << selectors_raw
196
+ next
197
+ end
198
+
199
+ selectors = selectors_raw.strip
200
+ at_name = selectors[/\A@([a-zA-Z-]+)/, 1]&.downcase
201
+ entering_non_scoping = at_name && NON_SCOPING_AT_RULES.include?(at_name) && suppress_below_depth.nil?
202
+
203
+ skip_scoping = !suppress_below_depth.nil? ||
204
+ selectors.start_with?("@") ||
205
+ selectors_raw.include?("[data-capsule=") ||
206
+ selectors.empty?
207
+
208
+ if skip_scoping
209
+ output << selectors_raw
210
+ if entering_non_scoping
211
+ suppress_below_depth = brace_depth
212
+ end
213
+ else
214
+ scoped_selectors = selectors.split(",").map do |selector|
215
+ selector = selector.strip
216
+ next selector if selector.empty?
217
+
218
+ if selector.start_with?(":host")
219
+ selector
220
+ .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
221
+ .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
222
+ .gsub(/^:host\b/, capsule_attr)
223
+ else
224
+ "#{capsule_attr} #{selector}"
225
+ end
226
+ end.join(", ")
227
+
228
+ leading = selectors_raw[/\A\s*/]
229
+ trailing = selectors_raw[/\s*\z/]
230
+ output << "#{leading}#{scoped_selectors}#{trailing}"
231
+ end
232
+
233
+ output << "{"
234
+ index += 1
235
+ brace_depth += 1
236
+ end
237
+
238
+ output
239
+ end
240
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
241
+
242
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- splits top-level CSS into hoistable and nestable parts
243
+ def self.hoist_top_level_at_rules(css)
244
+ hoisted = []
245
+ nestable = +""
246
+ index = 0
247
+ length = css.length
248
+
249
+ while index < length
250
+ while index < length && css[index].match?(/\s/)
251
+ nestable << css[index]
252
+ index += 1
253
+ end
254
+ break if index >= length
255
+
256
+ if css[index] == "@"
257
+ at_name = css[index..][/\A@([a-zA-Z-]+)/, 1]&.downcase
258
+ if at_name && HOISTABLE_AT_RULES.include?(at_name)
259
+ block, index = read_top_level_at_rule(css, index)
260
+ hoisted << block.strip
261
+ next
262
+ end
263
+ end
264
+
265
+ rule, index = read_top_level_rule(css, index)
266
+ nestable << rule
267
+ end
268
+
269
+ [hoisted.join("\n"), nestable]
270
+ end
271
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
272
+
273
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- semicolon vs braced at-rule forms
274
+ def self.read_top_level_at_rule(css, index)
275
+ start_index = index
276
+ length = css.length
277
+
278
+ while index < length && css[index] != "{" && css[index] != ";"
279
+ index += 1
280
+ end
281
+
282
+ if index < length && css[index] == ";"
283
+ index += 1
284
+ return [css[start_index...index], index]
285
+ end
286
+
287
+ raise ArgumentError, "Unterminated at-rule in CSS" if index >= length
288
+
289
+ index = consume_braced_block(css, index)
290
+ [css[start_index...index], index]
291
+ end
292
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
293
+
294
+ def self.read_top_level_rule(css, index)
295
+ start_index = index
296
+ length = css.length
297
+
298
+ while index < length && css[index] != "{"
299
+ index += 1
300
+ end
301
+
302
+ return [css[start_index...index], index] if index >= length
303
+
304
+ index = consume_braced_block(css, index)
305
+ [css[start_index...index], index]
306
+ end
307
+
308
+ # rubocop:disable Metrics/MethodLength -- brace depth walk with unterminated guard
309
+ def self.consume_braced_block(css, index)
310
+ length = css.length
311
+ raise ArgumentError, "Expected opening brace in CSS" unless css[index] == "{"
312
+
313
+ depth = 0
314
+ while index < length
315
+ case css[index]
316
+ when "{"
317
+ depth += 1
318
+ when "}"
319
+ depth -= 1
320
+ index += 1
321
+ return index if depth.zero?
322
+
323
+ next
324
+ end
325
+ index += 1
326
+ end
327
+
328
+ raise ArgumentError, "Unterminated CSS block"
329
+ end
330
+ # rubocop:enable Metrics/MethodLength
331
+
332
+ private_class_method :strip_comments, :validate_capsule_id!, :patch_selectors,
333
+ :hoist_top_level_at_rules, :read_top_level_at_rule, :read_top_level_rule,
334
+ :consume_braced_block
199
335
  end
336
+ # rubocop:enable Metrics/ModuleLength
200
337
  end
@@ -32,8 +32,11 @@ module StyleCapsule
32
32
  injected_body = StylesheetRegistry.inject_pending_head_stylesheets(body, view_context)
33
33
  return [status, headers, response] if injected_body.equal?(body)
34
34
 
35
+ close_response(response)
36
+
35
37
  headers = headers.dup
36
- headers["Content-Length"] = injected_body.bytesize.to_s if headers.key?("Content-Length")
38
+ headers.delete("Transfer-Encoding")
39
+ headers["Content-Length"] = injected_body.bytesize.to_s
37
40
 
38
41
  [status, headers, [injected_body]]
39
42
  end
@@ -41,19 +44,27 @@ module StyleCapsule
41
44
  def inject_response?(status, headers, response)
42
45
  return false unless SUCCESS_STATUS.cover?(status)
43
46
  return false unless html_content_type?(headers["Content-Type"])
44
- return false if chunked_response?(headers)
45
- return false if response.respond_to?(:to_ary) && response.to_ary == [""]
47
+ return false unless bufferable_body?(response)
48
+ return false if empty_body?(response)
46
49
 
47
50
  true
48
51
  end
49
52
 
50
- def chunked_response?(headers)
51
- transfer_encoding = headers["Transfer-Encoding"]
52
- transfer_encoding&.match?(/chunked/i)
53
+ # Rack 3: +to_ary+ means the body may be buffered without changing application behaviour.
54
+ # Streaming / Live bodies omit +to_ary+; leave those alone even when Transfer-Encoding is chunked.
55
+ def bufferable_body?(response)
56
+ response.respond_to?(:to_ary)
57
+ end
58
+
59
+ def empty_body?(response)
60
+ response.to_ary == [""]
53
61
  end
54
62
 
55
63
  def html_content_type?(content_type)
56
- return false if content_type.blank?
64
+ # Core path must not call ActiveSupport String#blank?
65
+ # rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
66
+ return false if content_type.nil? || content_type.empty?
67
+ # rubocop:enable Rails/Blank
57
68
 
58
69
  content_type.split(";", 2).first.to_s.strip.match?(HTML_CONTENT_TYPE)
59
70
  end
@@ -64,6 +75,10 @@ module StyleCapsule
64
75
  parts.join
65
76
  end
66
77
 
78
+ def close_response(response)
79
+ response.close if response.respond_to?(:close)
80
+ end
81
+
67
82
  def view_context_for(env)
68
83
  controller = env["action_controller.instance"]
69
84
  controller&.view_context