style_capsule 1.3.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
  #
@@ -159,9 +153,5 @@ module StyleCapsule
159
153
  def stylesheet_registry_tags(namespace: nil)
160
154
  StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
161
155
  end
162
-
163
- # @deprecated Use {#stylesheet_registry_tags} instead.
164
- # This method name will be removed in a future version.
165
- alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
166
156
  end
167
157
  end
@@ -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
@@ -10,13 +10,18 @@ module StyleCapsule
10
10
  #
11
11
  # Usage in Phlex layouts:
12
12
  # head do
13
- # stylesheet_registrymap_tags
13
+ # stylesheet_registry_tags
14
14
  # end
15
15
  #
16
16
  # Usage in Phlex components:
17
- # def view_template
18
- # register_stylesheet("stylesheets/user/my_component")
19
- # div { "Content" }
17
+ # class MyComponent < ApplicationComponent
18
+ # include StyleCapsule::PhlexHelper
19
+ # style_capsule namespace: :user # Set default namespace for register_stylesheet
20
+ #
21
+ # def view_template
22
+ # register_stylesheet("stylesheets/user/my_component") # Uses :user namespace automatically
23
+ # div { "Content" }
24
+ # end
20
25
  # end
21
26
  module PhlexHelper
22
27
  # Register a stylesheet file for head rendering
@@ -28,11 +33,18 @@ module StyleCapsule
28
33
  # div { "Content" }
29
34
  # end
30
35
  #
36
+ # If the component has a default namespace set via style_capsule or stylesheet_registry,
37
+ # it will be used automatically when namespace is not explicitly provided.
38
+ #
31
39
  # @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
32
- # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
40
+ # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses component's default or global default)
33
41
  # @param options [Hash] Options for stylesheet_link_tag
34
42
  # @return [void]
35
43
  def register_stylesheet(file_path, namespace: nil, **options)
44
+ # Use component's default namespace if not explicitly provided
45
+ if namespace.nil? && respond_to?(:class) && self.class.respond_to?(:stylesheet_namespace)
46
+ namespace = self.class.stylesheet_namespace
47
+ end
36
48
  StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
37
49
  end
38
50
 
@@ -48,23 +60,14 @@ module StyleCapsule
48
60
  # @return [void] Renders stylesheet tags via raw
49
61
  def stylesheet_registry_tags(namespace: nil)
50
62
  output = StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context, namespace: namespace)
51
- # Phlex's raw() requires the object to be marked as safe
52
- # Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
53
- # The output from render_head_stylesheets is already html_safe (SafeBuffer)
54
63
  output_string = output.to_s
55
64
 
56
65
  if respond_to?(:safe)
57
- # Real Phlex component - use raw() for rendering
58
- safe_content = safe(output_string)
59
- raw(safe_content)
66
+ raw(safe(output_string))
60
67
  end
61
68
 
62
- # 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.
63
70
  output_string
64
71
  end
65
-
66
- # @deprecated Use {#stylesheet_registry_tags} instead.
67
- # This method name will be removed in a future version.
68
- alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
69
72
  end
70
73
  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