style_capsule 1.4.0 → 2.0.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.
@@ -75,6 +75,8 @@ module StyleCapsule
75
75
  DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
76
76
  # Fallback directory for when default location is read-only (absolute path)
77
77
  FALLBACK_OUTPUT_DIR = "/tmp/style_capsule"
78
+ # Positive cache for resolved asset-relative paths (avoids repeated File.exist? on hot path)
79
+ FILE_PATH_CACHE_MAX = 512
78
80
 
79
81
  class << self
80
82
  attr_accessor :output_dir, :filename_pattern, :enabled, :fallback_dir
@@ -99,23 +101,24 @@ module StyleCapsule
99
101
  # filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
100
102
  # )
101
103
  def configure(output_dir: nil, filename_pattern: nil, enabled: true, fallback_dir: nil)
104
+ clear_file_path_hit_cache!
102
105
  @enabled = enabled
106
+ @output_dir = resolve_output_dir(output_dir)
107
+ @fallback_dir = resolve_path(fallback_dir, FALLBACK_OUTPUT_DIR)
108
+ @filename_pattern = filename_pattern || default_filename_pattern
109
+ end
103
110
 
104
- @output_dir = if output_dir
105
- output_dir.is_a?(Pathname) ? output_dir : Pathname.new(output_dir.to_s)
106
- elsif rails_available?
107
- Rails.root.join(DEFAULT_OUTPUT_DIR)
108
- else
109
- Pathname.new(DEFAULT_OUTPUT_DIR)
110
- end
111
+ def resolve_output_dir(output_dir)
112
+ return resolve_path(output_dir, DEFAULT_OUTPUT_DIR) if output_dir
113
+ return Rails.root.join(DEFAULT_OUTPUT_DIR) if rails_available?
111
114
 
112
- @fallback_dir = if fallback_dir
113
- fallback_dir.is_a?(Pathname) ? fallback_dir : Pathname.new(fallback_dir.to_s)
114
- else
115
- Pathname.new(FALLBACK_OUTPUT_DIR)
116
- end
115
+ Pathname.new(DEFAULT_OUTPUT_DIR)
116
+ end
117
117
 
118
- @filename_pattern = filename_pattern || default_filename_pattern
118
+ def resolve_path(dir, default_path)
119
+ return Pathname.new(default_path) unless dir
120
+
121
+ dir.is_a?(Pathname) ? dir : Pathname.new(dir.to_s)
119
122
  end
120
123
 
121
124
  # Write CSS content to file
@@ -124,6 +127,7 @@ module StyleCapsule
124
127
  # @param component_class [Class] Component class that generated the CSS
125
128
  # @param capsule_id [String] Capsule ID for the component
126
129
  # @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled/failed
130
+ # rubocop:disable Metrics/AbcSize -- writes asset file with fallback and instrumentation paths
127
131
  def write_css(css_content:, component_class:, capsule_id:)
128
132
  return nil unless enabled?
129
133
 
@@ -194,13 +198,11 @@ module StyleCapsule
194
198
  # Return relative path for stylesheet_link_tag
195
199
  # Path should be relative to app/assets
196
200
  # Handle case where output directory is not under rails_assets_root (e.g., in tests)
197
- begin
198
- file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
199
- rescue ArgumentError
200
- # If paths don't share a common prefix (e.g., in tests), return just the filename
201
- filename.gsub(/\.css$/, "")
202
- end
201
+ rel = asset_logical_path(file_path, filename)
202
+ remember_file_path_hit("#{component_class.name}:#{capsule_id}", rel)
203
+ rel
203
204
  end
205
+ # rubocop:enable Metrics/AbcSize
204
206
 
205
207
  # Check if file exists for given component and capsule
206
208
  #
@@ -223,6 +225,10 @@ module StyleCapsule
223
225
  def file_path_for(component_class:, capsule_id:)
224
226
  return nil unless enabled?
225
227
 
228
+ cache_key = "#{component_class.name}:#{capsule_id}"
229
+ hit = file_path_hit_cache_mutex.synchronize { file_path_hit_cache[cache_key] }
230
+ return hit if hit
231
+
226
232
  filename = generate_filename(component_class, capsule_id)
227
233
  file_path = output_directory.join(filename)
228
234
 
@@ -230,12 +236,9 @@ module StyleCapsule
230
236
 
231
237
  # Return relative path for stylesheet_link_tag
232
238
  # Handle case where output directory is not under rails_assets_root (e.g., in tests)
233
- begin
234
- file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
235
- rescue ArgumentError
236
- # If paths don't share a common prefix (e.g., in tests), return just the filename
237
- filename.gsub(/\.css$/, "")
238
- end
239
+ rel = asset_logical_path(file_path, filename)
240
+ remember_file_path_hit(cache_key, rel)
241
+ rel
239
242
  end
240
243
 
241
244
  # Ensure output directory exists
@@ -264,6 +267,7 @@ module StyleCapsule
264
267
  def clear_files
265
268
  return unless enabled?
266
269
 
270
+ clear_file_path_hit_cache!
267
271
  dir = output_directory
268
272
  return unless Dir.exist?(dir)
269
273
 
@@ -285,6 +289,39 @@ module StyleCapsule
285
289
 
286
290
  private
287
291
 
292
+ def file_path_hit_cache
293
+ @file_path_hit_cache ||= {}
294
+ end
295
+
296
+ def file_path_hit_cache_mutex
297
+ @file_path_hit_cache_mutex ||= Mutex.new
298
+ end
299
+
300
+ def remember_file_path_hit(cache_key, relative_path)
301
+ file_path_hit_cache_mutex.synchronize do
302
+ file_path_hit_cache[cache_key] = relative_path
303
+ while file_path_hit_cache.size > FILE_PATH_CACHE_MAX
304
+ file_path_hit_cache.shift
305
+ end
306
+ end
307
+ end
308
+
309
+ def clear_file_path_hit_cache!
310
+ file_path_hit_cache_mutex.synchronize do
311
+ file_path_hit_cache.clear
312
+ end
313
+ end
314
+
315
+ # Logical path for stylesheet_link_tag (no parent segments; safe for AssetPath validation)
316
+ def asset_logical_path(file_path, filename)
317
+ candidate = file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
318
+ return candidate unless candidate.split("/").any? { |segment| segment == ".." }
319
+
320
+ filename.gsub(/\.css$/, "")
321
+ rescue ArgumentError
322
+ filename.gsub(/\.css$/, "")
323
+ end
324
+
288
325
  # Get output directory (with default)
289
326
  def output_directory
290
327
  @output_dir ||= if rails_available?
@@ -40,6 +40,7 @@ module StyleCapsule
40
40
  # @param component_class [Class, String, nil] Optional component class for instrumentation
41
41
  # @return [String] CSS with scoped selectors
42
42
  # @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
43
44
  def self.scope_selectors(css_string, capsule_id, component_class: nil)
44
45
  return css_string if css_string.nil? || css_string.strip.empty?
45
46
 
@@ -61,8 +62,8 @@ module StyleCapsule
61
62
  css = css_string.dup
62
63
  capsule_attr = %([data-capsule="#{capsule_id}"])
63
64
 
64
- # Strip CSS comments to avoid interference with selector matching
65
- # Simple approach: remove /* ... */ comments (including multi-line)
65
+ # Strip CSS comments so they do not interfere with selector matching.
66
+ # Comments are removed from the output (typical for production CSS).
66
67
  css_without_comments = strip_comments(css)
67
68
 
68
69
  # Process CSS rule by rule
@@ -108,13 +109,10 @@ module StyleCapsule
108
109
  "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
109
110
  end
110
111
 
111
- # Restore comments in their original positions
112
- # Since we stripped comments, we need to put them back
113
- # For simplicity, we'll just return the processed CSS without comments
114
- # (comments are typically removed in production CSS anyway)
115
112
  css_without_comments
116
113
  end
117
114
  end
115
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
118
116
 
119
117
  # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
120
118
  #
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Injects stylesheets registered during body rendering into +<head>+ on the same request.
5
+ #
6
+ # Layouts call +stylesheet_registry_tags+ before the body, so +register_stylesheet+ in
7
+ # components runs too late for that call. This middleware appends pending link/style tags
8
+ # immediately before +</head>+ once the full HTML response is available.
9
+ class HeadInjectionMiddleware
10
+ HTML_CONTENT_TYPE = %r{\Atext/html}i
11
+ SUCCESS_STATUS = (200..299)
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ status, headers, response = @app.call(env)
19
+ return [status, headers, response] unless inject_response?(status, headers, response)
20
+ return [status, headers, response] unless StylesheetRegistry.pending_head_stylesheets?
21
+
22
+ maybe_inject_head_styles(status, headers, response, env)
23
+ end
24
+
25
+ private
26
+
27
+ def maybe_inject_head_styles(status, headers, response, env)
28
+ body = read_response_body(response)
29
+ return [status, headers, response] if body.empty?
30
+
31
+ view_context = view_context_for(env)
32
+ injected_body = StylesheetRegistry.inject_pending_head_stylesheets(body, view_context)
33
+ return [status, headers, response] if injected_body.equal?(body)
34
+
35
+ headers = headers.dup
36
+ headers["Content-Length"] = injected_body.bytesize.to_s if headers.key?("Content-Length")
37
+
38
+ [status, headers, [injected_body]]
39
+ end
40
+
41
+ def inject_response?(status, headers, response)
42
+ return false unless SUCCESS_STATUS.cover?(status)
43
+ 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 == [""]
46
+
47
+ true
48
+ end
49
+
50
+ def chunked_response?(headers)
51
+ transfer_encoding = headers["Transfer-Encoding"]
52
+ transfer_encoding&.match?(/chunked/i)
53
+ end
54
+
55
+ def html_content_type?(content_type)
56
+ return false if content_type.blank?
57
+
58
+ content_type.split(";", 2).first.to_s.strip.match?(HTML_CONTENT_TYPE)
59
+ end
60
+
61
+ def read_response_body(response)
62
+ parts = []
63
+ response.each { |part| parts << part.to_s }
64
+ parts.join
65
+ end
66
+
67
+ def view_context_for(env)
68
+ controller = env["action_controller.instance"]
69
+ controller&.view_context
70
+ end
71
+ end
72
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest/sha1"
4
+ require_relative "helper_scope_cache"
4
5
  # ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
5
6
 
6
7
  module StyleCapsule
@@ -24,6 +25,8 @@ module StyleCapsule
24
25
  # The CSS will be automatically scoped and content wrapped in a scoped div.
25
26
  # The <style> tag will be extracted and processed separately.
26
27
  module Helper
28
+ include HelperScopeCache
29
+
27
30
  # Maximum HTML content size (10MB) to prevent DoS attacks
28
31
  MAX_HTML_SIZE = 10_000_000
29
32
  # Generate capsule ID based on caller location for uniqueness
@@ -36,16 +39,7 @@ module StyleCapsule
36
39
 
37
40
  # Scope CSS content and return scoped CSS
38
41
  def scope_css(css_content, capsule_id)
39
- # Use thread-local cache to avoid reprocessing
40
- cache_key = "style_capsule_#{capsule_id}"
41
-
42
- if Thread.current[cache_key]
43
- return Thread.current[cache_key]
44
- end
45
-
46
- scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
47
- Thread.current[cache_key] = scoped_css
48
- scoped_css
42
+ scope_css_with_bounded_cache(css_content, capsule_id)
49
43
  end
50
44
 
51
45
  # ERB helper: automatically wraps content in scoped div and processes CSS
@@ -83,7 +77,14 @@ module StyleCapsule
83
77
  # <%= style_capsule(css_content, capsule_id: "test-123") do %>
84
78
  # <div class="section">Content</div>
85
79
  # <% end %>
86
- def style_capsule(css_content = nil, capsule_id: nil, &content_block)
80
+ #
81
+ # Or with tag option:
82
+ # <%= style_capsule(tag: :section) do %>
83
+ # <style>.section { color: red; }</style>
84
+ # <div class="section">Content</div>
85
+ # <% end %>
86
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- ERB helper extracts styles and wraps scoped markup
87
+ def style_capsule(css_content = nil, capsule_id: nil, tag: :div, &content_block)
87
88
  html_content = nil
88
89
 
89
90
  # If CSS content is provided as argument, use it
@@ -96,17 +97,9 @@ module StyleCapsule
96
97
  raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
97
98
  end
98
99
 
99
- # Extract <style> tags from content
100
- # Note: Pattern uses non-greedy matching (.*?) to minimize backtracking
101
- # Size limit (MAX_HTML_SIZE) mitigates ReDoS risk from malicious input
102
- style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
103
- if style_match
104
- css_content = style_match[1]
105
- # Use sub instead of gsub to only remove first occurrence (reduces backtracking)
106
- html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
107
- else
108
- # No style tag found, treat entire content as HTML
109
- css_content = nil
100
+ # Extract every <style> block; combine CSS, strip tags from markup
101
+ html_content, css_content = extract_styles_from_markup(full_content)
102
+ if css_content.nil?
110
103
  html_content = full_content
111
104
  end
112
105
  elsif css_content && block_given?
@@ -129,10 +122,11 @@ module StyleCapsule
129
122
 
130
123
  # Render style tag and wrapped content
131
124
  style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
132
- wrapped_content = content_tag(:div, raw(html_content), data: {capsule: capsule_id})
125
+ wrapped_content = content_tag(tag, raw(html_content), data: {capsule: capsule_id})
133
126
 
134
127
  (style_tag + wrapped_content).html_safe
135
128
  end
129
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
136
130
 
137
131
  # Register a stylesheet file for head rendering
138
132
  #
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module StyleCapsule
6
+ # Bounded per-thread cache for scoped CSS from ERB / standalone helpers (avoids unbounded Thread.current growth).
7
+ module HelperScopeCache
8
+ MAX_SCOPE_CACHE_ENTRIES = 256
9
+ STYLE_OPEN = %r{<style[^>]*>}im
10
+ STYLE_CLOSE = %r{</style>}im
11
+
12
+ private
13
+
14
+ # @param full_html [String]
15
+ # @return [Array(String, String, nil)] html_without_styles, combined_css (nil if none)
16
+ def extract_styles_from_markup(full_html)
17
+ scanner = StringScanner.new(full_html)
18
+ html_out = +""
19
+ segments = []
20
+
21
+ until scanner.eos?
22
+ break unless collect_style_segment!(scanner, html_out, segments)
23
+ end
24
+
25
+ combined = segments.empty? ? nil : segments.join("\n\n")
26
+ [html_out.strip, combined]
27
+ end
28
+
29
+ def collect_style_segment!(scanner, html_out, segments)
30
+ text = scanner.scan_until(STYLE_OPEN)
31
+ if text.nil?
32
+ html_out << scanner.rest
33
+ return false
34
+ end
35
+
36
+ html_out << text.sub(STYLE_OPEN, "")
37
+
38
+ css = scanner.scan_until(STYLE_CLOSE)
39
+ if css.nil?
40
+ html_out << scanner.rest
41
+ return false
42
+ end
43
+
44
+ segments << css.sub(STYLE_CLOSE, "")
45
+ true
46
+ end
47
+
48
+ def scope_css_with_bounded_cache(css_content, capsule_id)
49
+ fingerprint = Digest::SHA1.hexdigest(css_content.to_s)
50
+ cache_key = "style_capsule_#{capsule_id}_#{fingerprint}"
51
+
52
+ bucket = Thread.current[:style_capsule_scope_cache] ||= {order: [], hash: {}}
53
+ hash = bucket[:hash]
54
+ order = bucket[:order]
55
+
56
+ return hash[cache_key] if hash.key?(cache_key)
57
+
58
+ scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
59
+ evict_scope_cache_if_full!(hash, order)
60
+ hash[cache_key] = scoped_css
61
+ order << cache_key
62
+ scoped_css
63
+ end
64
+
65
+ def evict_scope_cache_if_full!(hash, order)
66
+ while order.size >= MAX_SCOPE_CACHE_ENTRIES
67
+ old = order.shift
68
+ hash.delete(old)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -12,6 +12,7 @@ module StyleCapsule
12
12
  # @example Subscribing to CSS processing events
13
13
  # ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |name, start, finish, id, payload|
14
14
  # duration_ms = (finish - start) * 1000
15
+ # # input_size is derived from :css_content when subscribers exist
15
16
  # input_size = payload[:input_size]
16
17
  # Rails.logger.info "CSS scoped in #{duration_ms}ms, input: #{input_size} bytes"
17
18
  # end
@@ -31,7 +32,7 @@ module StyleCapsule
31
32
  #
32
33
  # @return [Boolean]
33
34
  def self.available?
34
- defined?(ActiveSupport::Notifications)
35
+ !!defined?(ActiveSupport::Notifications)
35
36
  end
36
37
 
37
38
  # Instrument an operation with automatic timing and size metrics
@@ -52,10 +53,11 @@ module StyleCapsule
52
53
  # component_class: component_class.name,
53
54
  # capsule_id: capsule_id,
54
55
  # strategy: :selector_patching,
55
- # input_size: css_content.bytesize
56
+ # css_content: css_content
56
57
  # ) do
57
58
  # CssProcessor.scope_selectors(css_content, capsule_id)
58
59
  # end
60
+ # # When subscribers exist, :input_size is set from :css_content before the block runs.
59
61
  def self.instrument(event_name, payload = {}, &block)
60
62
  return yield unless available?
61
63
 
@@ -65,14 +67,7 @@ module StyleCapsule
65
67
  return yield
66
68
  end
67
69
 
68
- # Calculate input size if not provided but css_content/input is in payload
69
- unless payload[:input_size]
70
- if payload[:css_content]
71
- payload[:input_size] = payload[:css_content].bytesize
72
- elsif payload[:input]
73
- payload[:input_size] = payload[:input].bytesize
74
- end
75
- end
70
+ apply_input_size!(payload)
76
71
 
77
72
  # Use ActiveSupport::Notifications.instrument which automatically:
78
73
  # - Measures duration using monotonic time (available in event.duration)
@@ -87,6 +82,14 @@ module StyleCapsule
87
82
  result
88
83
  end
89
84
 
85
+ def self.apply_input_size!(payload)
86
+ return if payload[:input_size]
87
+
88
+ source = payload[:css_content] || payload[:input]
89
+ payload[:input_size] = source.bytesize if source
90
+ end
91
+ private_class_method :apply_input_size!
92
+
90
93
  # Instrument an event without a block (for events that don't have duration)
91
94
  #
92
95
  # @param event_name [String] Event name
@@ -115,23 +118,20 @@ module StyleCapsule
115
118
  # @param strategy [Symbol] Scoping strategy (:selector_patching or :nesting)
116
119
  # @param component_class [Class, String] Component class
117
120
  # @param capsule_id [String] Capsule ID
118
- # @param css_content [String] CSS content (for size calculation)
121
+ # @param css_content [String] CSS content (`:input_size` derived lazily when subscribed)
119
122
  # @yield The CSS processing operation
120
123
  # @return [String] Processed CSS
121
124
  def self.instrument_css_processing(strategy:, component_class:, capsule_id:, css_content:, &block)
122
125
  component_name = component_class.is_a?(Class) ? component_class.name : component_class.to_s
123
- input_size = css_content.bytesize
124
126
 
125
127
  instrument(
126
128
  "style_capsule.css_processor.scope",
127
129
  strategy: strategy,
128
130
  component_class: component_name,
129
131
  capsule_id: capsule_id,
130
- input_size: input_size
132
+ css_content: css_content
131
133
  ) do
132
134
  result = yield
133
- # Output size will be calculated by subscribers if needed
134
- # They can access it via: result.bytesize
135
135
  result
136
136
  end
137
137
  end
@@ -16,7 +16,7 @@ module StyleCapsule
16
16
  # Usage in Phlex components:
17
17
  # class MyComponent < ApplicationComponent
18
18
  # include StyleCapsule::PhlexHelper
19
- # styles_namespace :user # Set default namespace
19
+ # style_capsule namespace: :user # Set default namespace for register_stylesheet
20
20
  #
21
21
  # def view_template
22
22
  # register_stylesheet("stylesheets/user/my_component") # Uses :user namespace automatically
@@ -33,7 +33,7 @@ module StyleCapsule
33
33
  # div { "Content" }
34
34
  # end
35
35
  #
36
- # If the component has a default namespace set via styles_namespace or stylesheet_registry,
36
+ # If the component has a default namespace set via style_capsule or stylesheet_registry,
37
37
  # it will be used automatically when namespace is not explicitly provided.
38
38
  #
39
39
  # @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
@@ -60,18 +60,13 @@ module StyleCapsule
60
60
  # @return [void] Renders stylesheet tags via raw
61
61
  def stylesheet_registry_tags(namespace: nil)
62
62
  output = StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context, namespace: namespace)
63
- # Phlex's raw() requires the object to be marked as safe
64
- # Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
65
- # The output from render_head_stylesheets is already html_safe (SafeBuffer)
66
63
  output_string = output.to_s
67
64
 
68
65
  if respond_to?(:safe)
69
- # Real Phlex component - use raw() for rendering
70
- safe_content = safe(output_string)
71
- raw(safe_content)
66
+ raw(safe(output_string))
72
67
  end
73
68
 
74
- # Always return the output string for testing/compatibility
69
+ # Phlex `raw` may return nil when writing to the buffer; callers/tests expect the HTML string.
75
70
  output_string
76
71
  end
77
72
  end
@@ -4,6 +4,10 @@ module StyleCapsule
4
4
  # Railtie to automatically include StyleCapsule helpers in Rails
5
5
  if defined?(Rails::Railtie)
6
6
  class Railtie < Rails::Railtie
7
+ config.style_capsule = ActiveSupport::OrderedOptions.new
8
+ config.style_capsule.run_on_precompile = true
9
+ config.style_capsule.head_injection_middleware = true
10
+
7
11
  # Automatically include ERB helper in ActionView::Base (standard Rails pattern)
8
12
  # This makes helpers available in all ERB templates automatically
9
13
  ActiveSupport.on_load(:action_view) do
@@ -53,6 +57,13 @@ module StyleCapsule
53
57
  load File.expand_path("../tasks/style_capsule.rake", __dir__)
54
58
  end
55
59
 
60
+ initializer "style_capsule.head_injection_middleware", after: :load_config_initializers do |app|
61
+ next unless app.config.style_capsule.head_injection_middleware
62
+
63
+ require_relative "head_injection_middleware"
64
+ app.config.middleware.use StyleCapsule::HeadInjectionMiddleware
65
+ end
66
+
56
67
  # Note: PhlexHelper should be included explicitly in ApplicationComponent
57
68
  # or your base Phlex component class, not automatically via Railtie
58
69
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest/sha1"
4
4
  require "cgi"
5
+ require_relative "helper_scope_cache"
5
6
 
6
7
  module StyleCapsule
7
8
  # Standalone helper module for use without Rails
@@ -27,6 +28,8 @@ module StyleCapsule
27
28
  # "<style>.section { color: red; }</style><div class='section'>Content</div>"
28
29
  # end
29
30
  module StandaloneHelper
31
+ include HelperScopeCache
32
+
30
33
  # Maximum HTML content size (10MB) to prevent DoS attacks
31
34
  MAX_HTML_SIZE = 10_000_000
32
35
 
@@ -40,16 +43,7 @@ module StyleCapsule
40
43
 
41
44
  # Scope CSS content and return scoped CSS
42
45
  def scope_css(css_content, capsule_id)
43
- # Use thread-local cache to avoid reprocessing
44
- cache_key = "style_capsule_#{capsule_id}"
45
-
46
- if Thread.current[cache_key]
47
- return Thread.current[cache_key]
48
- end
49
-
50
- scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
51
- Thread.current[cache_key] = scoped_css
52
- scoped_css
46
+ scope_css_with_bounded_cache(css_content, capsule_id)
53
47
  end
54
48
 
55
49
  # Generate HTML tag without Rails helpers
@@ -59,6 +53,7 @@ module StyleCapsule
59
53
  # @param options [Hash] HTML attributes
60
54
  # @param block [Proc] Block for tag content
61
55
  # @return [String] HTML string
56
+ # rubocop:disable Metrics/AbcSize -- serializes flat and nested HTML attributes
62
57
  def content_tag(tag, content = nil, **options, &block)
63
58
  tag_name = tag.to_s
64
59
  content = capture(&block) if block_given? && content.nil?
@@ -76,6 +71,7 @@ module StyleCapsule
76
71
  attrs = " #{attrs}" unless attrs.empty?
77
72
  "<#{tag_name}#{attrs}>#{content}</#{tag_name}>"
78
73
  end
74
+ # rubocop:enable Metrics/AbcSize
79
75
 
80
76
  # Capture block content (simplified version without Rails)
81
77
  #
@@ -114,7 +110,8 @@ module StyleCapsule
114
110
  # @param capsule_id [String, nil] Optional capsule ID
115
111
  # @param content_block [Proc] Block containing HTML content
116
112
  # @return [String] HTML with scoped CSS and wrapped content
117
- def style_capsule(css_content = nil, capsule_id: nil, &content_block)
113
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- standalone helper mirrors Rails helper flow
114
+ def style_capsule(css_content = nil, capsule_id: nil, tag: :div, &content_block)
118
115
  html_content = nil
119
116
 
120
117
  # If CSS content is provided as argument, use it
@@ -127,13 +124,8 @@ module StyleCapsule
127
124
  raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
128
125
  end
129
126
 
130
- # Extract <style> tags from content
131
- style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
132
- if style_match
133
- css_content = style_match[1]
134
- html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
135
- else
136
- css_content = nil
127
+ html_content, css_content = extract_styles_from_markup(full_content)
128
+ if css_content.nil?
137
129
  html_content = full_content
138
130
  end
139
131
  elsif css_content && block_given?
@@ -156,10 +148,11 @@ module StyleCapsule
156
148
 
157
149
  # Render style tag and wrapped content
158
150
  style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
159
- wrapped_content = content_tag(:div, raw(html_content), data: {capsule: capsule_id})
151
+ wrapped_content = content_tag(tag, raw(html_content), data: {capsule: capsule_id})
160
152
 
161
153
  html_safe(style_tag + wrapped_content)
162
154
  end
155
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
163
156
 
164
157
  # Register a stylesheet file for head rendering
165
158
  #