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.
@@ -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
  #
@@ -179,10 +172,6 @@ module StyleCapsule
179
172
  StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
180
173
  end
181
174
 
182
- # @deprecated Use {#stylesheet_registry_tags} instead.
183
- # This method name will be removed in a future version.
184
- alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
185
-
186
175
  private
187
176
 
188
177
  # Escape HTML attribute value
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "instrumentation"
4
+ require_relative "asset_path"
5
+
3
6
  module StyleCapsule
4
7
  # Helper to determine the parent class for StylesheetRegistry
5
8
  # ActiveSupport::CurrentAttributes is optional - if ActiveSupport is loaded,
@@ -13,10 +16,13 @@ module StyleCapsule
13
16
 
14
17
  # Hybrid registry for stylesheet files that need to be injected into <head>
15
18
  #
16
- # Uses a process-wide manifest for static file paths (like Propshaft) and request-scoped
17
- # storage for inline CSS. This approach:
18
- # - Collects static file paths once per process (no rebuilding on each request)
19
- # - Stores inline CSS per-request (since it's component-specific)
19
+ # Uses a process-wide manifest for eager registrations, request-scoped storage for
20
+ # render-time file paths and inline CSS, and optional Rack middleware to inject
21
+ # stylesheets registered during body rendering into +<head>+ on the same request.
22
+ #
23
+ # This approach:
24
+ # - Keeps eager file paths in a process-wide manifest (class load / boot time)
25
+ # - Stores render-time file paths and inline CSS per request
20
26
  # - Works correctly with both threaded and forked web servers (Puma, Unicorn, etc.)
21
27
  #
22
28
  # Supports namespaces for separation of stylesheets (e.g., "admin", "user", "public").
@@ -33,9 +39,12 @@ module StyleCapsule
33
39
  # end
34
40
  # end
35
41
  #
36
- # @example Register a stylesheet file manually (default namespace)
42
+ # @example Register a stylesheet during rendering (request-scoped)
37
43
  # StyleCapsule::StylesheetRegistry.register('stylesheets/my_component')
38
44
  #
45
+ # @example Register a stylesheet eagerly at boot or class load (process-wide manifest)
46
+ # StyleCapsule::StylesheetRegistry.register_eager('stylesheets/my_component', namespace: :user)
47
+ #
39
48
  # @example Register a stylesheet with a namespace
40
49
  # StyleCapsule::StylesheetRegistry.register('stylesheets/admin/dashboard', namespace: :admin)
41
50
  #
@@ -65,20 +74,21 @@ module StyleCapsule
65
74
  DEFAULT_NAMESPACE = :default
66
75
 
67
76
  # Process-wide manifest for static file paths (like Propshaft)
68
- # Organized by namespace: { namespace => Set of {file_path, options} hashes }
69
- @manifest = {} # rubocop:disable Style/ClassVars
77
+ # Organized by namespace: { namespace => { logical_path => { file_path:, options: } } }
78
+ @manifest = {} # rubocop:disable Style/ClassVars, ThreadSafety/MutableClassInstanceVariable
70
79
 
71
80
  # Process-wide cache for inline CSS (with expiration support)
72
81
  # Structure: { cache_key => { css_content: String, cached_at: Time, expires_at: Time } }
73
- @inline_cache = {} # rubocop:disable Style/ClassVars
82
+ @inline_cache = {} # rubocop:disable Style/ClassVars, ThreadSafety/MutableClassInstanceVariable
74
83
 
75
84
  # Track last cleanup time for lazy cleanup (prevents excessive cleanup calls)
76
85
  @last_cleanup_time = nil # rubocop:disable Style/ClassVars
77
86
 
78
- # Request-scoped storage for inline CSS only
87
+ # Request-scoped storage for inline CSS and render-time file registrations
79
88
  # Only define attribute if we're inheriting from CurrentAttributes
80
89
  if defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
81
90
  attribute :inline_stylesheets
91
+ attribute :request_file_stylesheets
82
92
  end
83
93
 
84
94
  class << self
@@ -125,19 +135,41 @@ module StyleCapsule
125
135
  end
126
136
  end
127
137
 
138
+ # Get request-scoped file stylesheets (thread-local fallback if not using CurrentAttributes)
139
+ def request_file_stylesheets
140
+ if using_current_attributes?
141
+ inst = instance
142
+ inst&.request_file_stylesheets || {}
143
+ else
144
+ Thread.current[:style_capsule_request_file_stylesheets] ||= {}
145
+ end
146
+ end
147
+
148
+ # Set request-scoped file stylesheets (thread-local fallback if not using CurrentAttributes)
149
+ def request_file_stylesheets=(value)
150
+ if using_current_attributes?
151
+ inst = instance
152
+ inst.request_file_stylesheets = value if inst
153
+ else
154
+ Thread.current[:style_capsule_request_file_stylesheets] = value
155
+ end
156
+ end
157
+
128
158
  # Get instance (for CurrentAttributes compatibility)
129
159
  def instance
130
160
  if using_current_attributes?
131
161
  # Call the CurrentAttributes instance method from parent class
132
162
  super
133
163
  else
134
- # Return a simple object that responds to inline_stylesheets
164
+ # Return a simple object that responds to request-scoped attributes
135
165
  # This is mainly for compatibility with code that might call instance.inline_stylesheets
136
166
  registry_class = self
137
167
  @_standalone_instance ||= begin
138
168
  obj = Object.new
139
169
  obj.define_singleton_method(:inline_stylesheets) { registry_class.inline_stylesheets }
140
170
  obj.define_singleton_method(:inline_stylesheets=) { |v| registry_class.inline_stylesheets = v }
171
+ obj.define_singleton_method(:request_file_stylesheets) { registry_class.request_file_stylesheets }
172
+ obj.define_singleton_method(:request_file_stylesheets=) { |v| registry_class.request_file_stylesheets = v }
141
173
  obj
142
174
  end
143
175
  end
@@ -155,33 +187,65 @@ module StyleCapsule
155
187
  namespace.to_sym
156
188
  end
157
189
 
158
- # Register a stylesheet file path for head rendering
190
+ # Register a stylesheet file path during rendering (request-scoped).
159
191
  #
160
- # Static file paths are stored in process-wide manifest (collected once per process).
161
- # This is similar to Propshaft's manifest approach - files are static, so we can
162
- # collect them process-wide without rebuilding on each request.
192
+ # Render-time registrations are stored per request and emitted by
193
+ # +render_head_stylesheets+ when the layout head runs before the body, or by
194
+ # +HeadInjectionMiddleware+ when components register stylesheets later in the response.
163
195
  #
164
196
  # Files registered here are served through Rails asset pipeline (via stylesheet_link_tag).
165
- # This includes both:
166
- # - Pre-built files from assets:precompile (already in asset pipeline)
167
- # - Dynamically written files (written during runtime, also served through asset pipeline)
168
- #
169
- # The Set automatically deduplicates entries, so registering the same file multiple times
170
- # (e.g., when the same component renders multiple times) is safe and efficient.
171
197
  #
172
198
  # @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
173
199
  # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
174
200
  # @param options [Hash] Options for stylesheet_link_tag
175
201
  # @return [void]
176
202
  def self.register(file_path, namespace: nil, **options)
203
+ register_request_file(file_path, namespace: namespace, **options)
204
+ end
205
+
206
+ # Register a stylesheet file path eagerly (process-wide manifest).
207
+ #
208
+ # Use at class load or boot time when the stylesheet should always be available in
209
+ # +render_head_stylesheets+ without waiting for a component render.
210
+ #
211
+ # @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
212
+ # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
213
+ # @param options [Hash] Options for stylesheet_link_tag
214
+ # @return [void]
215
+ def self.register_eager(file_path, namespace: nil, **options)
177
216
  ns = normalize_namespace(namespace)
178
- @manifest[ns] ||= Set.new
179
- # Use a hash with file_path and options as the key to avoid duplicates
180
- # Set will automatically deduplicate based on hash equality
181
- entry = {file_path: file_path, options: options}
182
- @manifest[ns] << entry
217
+ path = AssetPath.validate_logical_path!(file_path)
218
+
219
+ Instrumentation.instrument_registration(
220
+ namespace: ns,
221
+ file_path: path,
222
+ inline_size: nil,
223
+ cache_strategy: :none
224
+ ) do
225
+ @manifest[ns] ||= {}
226
+ @manifest[ns][path] = {file_path: path, options: options}
227
+ end
183
228
  end
184
229
 
230
+ # @api private
231
+ def self.register_request_file(file_path, namespace: nil, **options)
232
+ ns = normalize_namespace(namespace)
233
+ path = AssetPath.validate_logical_path!(file_path)
234
+
235
+ Instrumentation.instrument_registration(
236
+ namespace: ns,
237
+ file_path: path,
238
+ inline_size: nil,
239
+ cache_strategy: :none
240
+ ) do
241
+ registry = request_file_stylesheets
242
+ registry[ns] ||= {}
243
+ registry[ns][path] = {file_path: path, options: options}
244
+ self.request_file_stylesheets = registry
245
+ end
246
+ end
247
+ private_class_method :register_request_file
248
+
185
249
  # Register inline CSS for head rendering
186
250
  #
187
251
  # Inline CSS can be cached based on cache configuration. Supports:
@@ -201,6 +265,7 @@ module StyleCapsule
201
265
  # @param component_class [Class, nil] Component class (for :file strategy)
202
266
  # @param stylesheet_link_options [Hash, nil] Options for stylesheet_link_tag (for :file strategy)
203
267
  # @return [void]
268
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- coordinates file, inline, and cache registration
204
269
  def self.register_inline(css_content, namespace: nil, capsule_id: nil, cache_key: nil, cache_strategy: :none, cache_ttl: nil, cache_proc: nil, component_class: nil, stylesheet_link_options: nil)
205
270
  ns = normalize_namespace(namespace)
206
271
 
@@ -215,7 +280,7 @@ module StyleCapsule
215
280
 
216
281
  if existing_path
217
282
  # File exists (pre-built or previously written), register it as a file path
218
- # The Set in @manifest will deduplicate if the same file is registered multiple times
283
+ # The manifest deduplicates by logical path if the same file is registered multiple times
219
284
  # This file will be served through Rails asset pipeline (stylesheet_link_tag)
220
285
  link_options = stylesheet_link_options || {}
221
286
  register(existing_path, namespace: namespace, **link_options)
@@ -248,21 +313,28 @@ module StyleCapsule
248
313
  # Use cached CSS if available, otherwise use provided CSS
249
314
  final_css = cached_css || css_content
250
315
 
251
- # Store in request-scoped registry
252
- registry = inline_stylesheets
253
- registry[ns] ||= []
254
- registry[ns] << {
255
- type: :inline,
256
- css_content: final_css,
257
- capsule_id: capsule_id
258
- }
259
- self.inline_stylesheets = registry
316
+ Instrumentation.instrument_registration(
317
+ namespace: ns,
318
+ file_path: nil,
319
+ inline_size: final_css.bytesize,
320
+ cache_strategy: cache_strategy
321
+ ) do
322
+ registry = inline_stylesheets
323
+ registry[ns] ||= []
324
+ registry[ns] << {
325
+ type: :inline,
326
+ css_content: final_css,
327
+ capsule_id: capsule_id
328
+ }
329
+ self.inline_stylesheets = registry
330
+ end
260
331
 
261
332
  # Cache the CSS if strategy is enabled and not already cached
262
333
  if cache_strategy != :none && cache_key && !cached_css && cache_strategy != :file
263
334
  cache_inline_css(cache_key, css_content, cache_strategy: cache_strategy, cache_ttl: cache_ttl, cache_proc: cache_proc, capsule_id: capsule_id, namespace: ns)
264
335
  end
265
336
  end
337
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
266
338
 
267
339
  # Get cached inline CSS if available and not expired
268
340
  #
@@ -291,7 +363,9 @@ module StyleCapsule
291
363
  return nil if cache_ttl && cached_entry[:expires_at] && current_time > cached_entry[:expires_at]
292
364
  when :proc
293
365
  return nil unless cache_proc
294
- # Proc should validate cache entry
366
+ # Re-validation: the proc is invoked on every read with the *current* request's CSS.
367
+ # Return [_, true, _] from the proc to keep using the cached entry; false invalidates it.
368
+ # (This is closer to a conditional cache than a blind key/value store.)
295
369
  _key, should_use, _expires = cache_proc.call(css_content, capsule_id, namespace)
296
370
  return nil unless should_use
297
371
  end
@@ -401,9 +475,9 @@ module StyleCapsule
401
475
 
402
476
  # Get all registered file paths from process-wide manifest (organized by namespace)
403
477
  #
404
- # @return [Hash<Symbol, Set<Hash>>] Hash of namespace => set of file registrations
478
+ # @return [Hash<Symbol, Array<Hash>>] Hash of namespace => array of file registrations
405
479
  def self.manifest_files
406
- @manifest.dup
480
+ @manifest.transform_values { |h| h.values }
407
481
  end
408
482
 
409
483
  # Get all registered inline stylesheets for current request (organized by namespace)
@@ -413,38 +487,51 @@ module StyleCapsule
413
487
  inline_stylesheets
414
488
  end
415
489
 
490
+ # Get all request-scoped file stylesheets for the current request (organized by namespace)
491
+ #
492
+ # @return [Hash<Symbol, Hash<String, Hash>>] Hash of namespace => logical path => registration
493
+ def self.request_stylesheet_files
494
+ request_file_stylesheets
495
+ end
496
+
416
497
  # Get all stylesheets (files + inline) for a specific namespace
417
498
  #
418
499
  # @param namespace [Symbol, String, nil] Namespace identifier (nil/blank uses default)
419
500
  # @return [Array<Hash>] Array of stylesheet registrations for the namespace
420
501
  def self.stylesheets_for(namespace: nil)
421
502
  ns = normalize_namespace(namespace)
422
- result = []
423
-
424
- # Add process-wide file paths
425
- if @manifest[ns]
426
- result.concat(@manifest[ns].to_a)
427
- end
503
+ result = merged_file_registrations_for_namespace(ns)
428
504
 
429
- # Add request-scoped inline CSS
430
505
  inline = request_inline_stylesheets[ns] || []
431
506
  result.concat(inline)
432
507
 
433
508
  result
434
509
  end
435
510
 
436
- # Clear request-scoped inline stylesheets (does not clear process-wide manifest)
511
+ # Whether request-scoped stylesheets remain to inject into +<head>+
512
+ #
513
+ # @return [Boolean]
514
+ def self.pending_head_stylesheets?
515
+ !pending_request_stylesheets.empty?
516
+ end
517
+
518
+ # Clear request-scoped inline CSS and render-time file registrations (does not clear process-wide manifest)
437
519
  #
438
520
  # @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
439
521
  # @return [void]
440
522
  def self.clear(namespace: nil)
441
523
  if namespace.nil?
442
524
  self.inline_stylesheets = {}
525
+ self.request_file_stylesheets = {}
443
526
  else
444
527
  ns = normalize_namespace(namespace)
445
- registry = inline_stylesheets
446
- registry.delete(ns)
447
- self.inline_stylesheets = registry
528
+ inline_registry = inline_stylesheets
529
+ inline_registry.delete(ns)
530
+ self.inline_stylesheets = inline_registry
531
+
532
+ file_registry = request_file_stylesheets
533
+ file_registry.delete(ns)
534
+ self.request_file_stylesheets = file_registry
448
535
  end
449
536
  end
450
537
 
@@ -464,8 +551,12 @@ module StyleCapsule
464
551
  # Render registered stylesheets as HTML
465
552
  #
466
553
  # This should be called in the layout's <head> section.
467
- # Combines process-wide file manifest with request-scoped inline CSS.
468
- # Automatically clears request-scoped inline CSS after rendering (manifest persists).
554
+ # Combines eager manifest files with request-scoped file paths and inline CSS
555
+ # registered before this call. Stylesheets registered later in the response are
556
+ # injected into +<head>+ by +HeadInjectionMiddleware+ when enabled.
557
+ #
558
+ # Automatically clears request-scoped inline CSS and file paths after rendering
559
+ # for the selected namespace(s). The eager manifest persists.
469
560
  #
470
561
  # @param view_context [ActionView::Base, nil] The view context (for helpers like content_tag, stylesheet_link_tag)
471
562
  # In ERB: pass `self` (the view context)
@@ -473,22 +564,16 @@ module StyleCapsule
473
564
  # If nil, falls back to basic HTML generation
474
565
  # @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all namespaces)
475
566
  # @return [String] HTML-safe string with stylesheet tags
567
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity -- renders mixed inline and file registrations
476
568
  def self.render_head_stylesheets(view_context = nil, namespace: nil)
477
569
  if namespace.nil? || namespace.to_s.strip.empty?
478
- # Render all namespaces
479
- all_stylesheets = []
570
+ all_stylesheets = merged_file_registrations_all_namespaces
480
571
 
481
- # Collect from process-wide manifest (all namespaces)
482
- @manifest.each do |ns, files|
483
- all_stylesheets.concat(files.to_a)
484
- end
485
-
486
- # Collect from request-scoped inline CSS (all namespaces)
487
572
  request_inline_stylesheets.each do |_ns, inline|
488
573
  all_stylesheets.concat(inline)
489
574
  end
490
575
 
491
- clear # Clear request-scoped inline CSS only
576
+ clear # Clear request-scoped inline CSS and file paths only
492
577
  return safe_string("") if all_stylesheets.empty?
493
578
 
494
579
  all_stylesheets.map do |stylesheet|
@@ -503,7 +588,7 @@ module StyleCapsule
503
588
  # Render specific namespace
504
589
  ns = normalize_namespace(namespace)
505
590
  stylesheets = stylesheets_for(namespace: ns).dup
506
- clear(namespace: ns) # Clear request-scoped inline CSS only
591
+ clear(namespace: ns) # Clear request-scoped inline CSS and file paths only
507
592
 
508
593
  return safe_string("") if stylesheets.empty?
509
594
 
@@ -517,28 +602,123 @@ module StyleCapsule
517
602
 
518
603
  end
519
604
  end
605
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
520
606
 
521
607
  # Check if there are any registered stylesheets
522
608
  #
523
609
  # @param namespace [Symbol, String, nil] Optional namespace to check (nil checks all)
524
610
  # @return [Boolean]
611
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- checks manifest and request-scoped registries
525
612
  def self.any?(namespace: nil)
526
613
  if namespace.nil?
527
614
  # Check process-wide manifest
528
615
  has_files = !@manifest.empty? && @manifest.values.any? { |files| !files.empty? }
616
+ # Check request-scoped file paths
617
+ request_files = request_file_stylesheets
618
+ has_request_files = !request_files.empty? && request_files.values.any? { |files| !files.empty? }
529
619
  # Check request-scoped inline CSS
530
620
  inline = request_inline_stylesheets
531
621
  has_inline = !inline.empty? && inline.values.any? { |stylesheets| !stylesheets.empty? }
532
- has_files || has_inline
622
+ has_files || has_request_files || has_inline
533
623
  else
534
624
  ns = normalize_namespace(namespace)
535
625
  # Check process-wide manifest
536
626
  has_files = @manifest[ns] && !@manifest[ns].empty?
627
+ # Check request-scoped file paths
628
+ has_request_files = request_file_stylesheets[ns] && !request_file_stylesheets[ns].empty?
537
629
  # Check request-scoped inline CSS
538
630
  has_inline = request_inline_stylesheets[ns] && !request_inline_stylesheets[ns].empty?
539
- !!(has_files || has_inline)
631
+ !!(has_files || has_request_files || has_inline)
632
+ end
633
+ end
634
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
635
+
636
+ # Inject pending request-scoped stylesheets into an HTML document before +</head>+.
637
+ #
638
+ # Used by +HeadInjectionMiddleware+ after the body has rendered and components have
639
+ # called +register_stylesheet+ or +register_inline+.
640
+ #
641
+ # @param html [String] Full HTML response body
642
+ # @param view_context [ActionView::Base, nil] View context for +stylesheet_link_tag+
643
+ # @return [String] HTML with pending stylesheet tags injected, or the original HTML
644
+ def self.inject_pending_head_stylesheets(html, view_context = nil)
645
+ pending_stylesheets = pending_request_stylesheets
646
+ return html if pending_stylesheets.empty?
647
+
648
+ closing_head_index = html.match(%r{</head>}i)&.begin(0)
649
+ return html unless closing_head_index
650
+
651
+ tags = render_stylesheet_tags(pending_stylesheets, view_context)
652
+ return html if tags.empty?
653
+
654
+ injected = html.dup
655
+ injected.insert(closing_head_index, "#{tags}\n")
656
+ clear
657
+ injected
658
+ end
659
+
660
+ # @api private
661
+ def self.merged_file_registrations_for_namespace(namespace)
662
+ merge_file_registrations(@manifest[namespace], request_file_stylesheets[namespace])
663
+ end
664
+
665
+ # @api private
666
+ def self.merged_file_registrations_all_namespaces
667
+ merged = {}
668
+
669
+ @manifest.each do |ns, files|
670
+ files.each_value do |entry|
671
+ merged[[ns, entry[:file_path]]] = entry
672
+ end
540
673
  end
674
+
675
+ request_file_stylesheets.each do |ns, files|
676
+ files.each_value do |entry|
677
+ merged[[ns, entry[:file_path]]] = entry
678
+ end
679
+ end
680
+
681
+ merged.values
682
+ end
683
+
684
+ # @api private
685
+ def self.merge_file_registrations(eager_files, request_files)
686
+ merged = {}
687
+ eager_files&.each_value { |entry| merged[entry[:file_path]] = entry }
688
+ request_files&.each_value { |entry| merged[entry[:file_path]] = entry }
689
+ merged.values
690
+ end
691
+
692
+ # @api private
693
+ def self.pending_request_stylesheets
694
+ stylesheets = []
695
+
696
+ request_file_stylesheets.each_value do |files|
697
+ stylesheets.concat(files.values)
698
+ end
699
+
700
+ request_inline_stylesheets.each_value do |inline_styles|
701
+ stylesheets.concat(inline_styles)
702
+ end
703
+
704
+ stylesheets
705
+ end
706
+
707
+ # @api private
708
+ def self.render_stylesheet_tags(stylesheets, view_context)
709
+ return safe_string("") if stylesheets.empty?
710
+
711
+ stylesheets.map do |stylesheet|
712
+ if stylesheet[:type] == :inline
713
+ render_inline_stylesheet(stylesheet, view_context)
714
+ else
715
+ render_file_stylesheet(stylesheet, view_context)
716
+ end
717
+ end.join("\n").then { |output| safe_string(output) }.to_s
541
718
  end
719
+ private_class_method :pending_request_stylesheets, :render_stylesheet_tags,
720
+ :merged_file_registrations_for_namespace, :merged_file_registrations_all_namespaces,
721
+ :merge_file_registrations
542
722
 
543
723
  # Render a file-based stylesheet
544
724
  def self.render_file_stylesheet(stylesheet, view_context)
@@ -548,7 +728,7 @@ module StyleCapsule
548
728
  if view_context&.respond_to?(:stylesheet_link_tag)
549
729
  view_context.stylesheet_link_tag(file_path, **options)
550
730
  else
551
- # Fallback if no view context
731
+ # Fallback if no view context (logical path validated in .register)
552
732
  href = "/assets/#{file_path}.css"
553
733
  tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
554
734
  safe_string(%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StyleCapsule
4
- VERSION = "1.3.0"
4
+ VERSION = "2.0.0"
5
5
  end