style_capsule 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,584 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Helper to determine the parent class for StylesheetRegistry
5
+ # ActiveSupport::CurrentAttributes is optional - if ActiveSupport is loaded,
6
+ # it will be available. Otherwise, we fall back to Object.
7
+ #
8
+ # This is evaluated at class definition time, so it can't be stubbed.
9
+ # For testing fallback paths, use the instance methods that check availability.
10
+ def self.stylesheet_registry_parent_class
11
+ defined?(ActiveSupport::CurrentAttributes) ? ActiveSupport::CurrentAttributes : Object
12
+ end
13
+
14
+ # Hybrid registry for stylesheet files that need to be injected into <head>
15
+ #
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)
20
+ # - Works correctly with both threaded and forked web servers (Puma, Unicorn, etc.)
21
+ #
22
+ # Supports namespaces for separation of stylesheets (e.g., "admin", "user", "public").
23
+ #
24
+ # @example Usage in a component with head rendering
25
+ # class MyComponent < ApplicationComponent
26
+ # include StyleCapsule::Component
27
+ # stylesheet_registry # Enable head rendering
28
+ #
29
+ # def component_styles
30
+ # <<~CSS
31
+ # .section { color: red; }
32
+ # CSS
33
+ # end
34
+ # end
35
+ #
36
+ # @example Register a stylesheet file manually (default namespace)
37
+ # StyleCapsule::StylesheetRegistry.register('stylesheets/my_component')
38
+ #
39
+ # @example Register a stylesheet with a namespace
40
+ # StyleCapsule::StylesheetRegistry.register('stylesheets/admin/dashboard', namespace: :admin)
41
+ #
42
+ # @example Usage in layout (ERB) - render all namespaces
43
+ # # In app/views/layouts/application.html.erb
44
+ # <head>
45
+ # <%= StyleCapsule::StylesheetRegistry.render_head_stylesheets(self) %>
46
+ # </head>
47
+ #
48
+ # @example Usage in layout (ERB) - render specific namespace
49
+ # <head>
50
+ # <%= StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: :admin) %>
51
+ # </head>
52
+ #
53
+ # @example Usage in layout (Phlex)
54
+ # # In app/views/layouts/application_layout.rb
55
+ # def view_template(&block)
56
+ # html do
57
+ # head do
58
+ # raw StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context)
59
+ # end
60
+ # body(&block)
61
+ # end
62
+ # end
63
+ class StylesheetRegistry < StyleCapsule.stylesheet_registry_parent_class
64
+ # Default namespace for backward compatibility
65
+ DEFAULT_NAMESPACE = :default
66
+
67
+ # 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
70
+
71
+ # Process-wide cache for inline CSS (with expiration support)
72
+ # Structure: { cache_key => { css_content: String, cached_at: Time, expires_at: Time } }
73
+ @inline_cache = {} # rubocop:disable Style/ClassVars
74
+
75
+ # Track last cleanup time for lazy cleanup (prevents excessive cleanup calls)
76
+ @last_cleanup_time = nil # rubocop:disable Style/ClassVars
77
+
78
+ # Request-scoped storage for inline CSS only
79
+ # Only define attribute if we're inheriting from CurrentAttributes
80
+ if defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
81
+ attribute :inline_stylesheets
82
+ end
83
+
84
+ class << self
85
+ attr_reader :manifest, :inline_cache
86
+
87
+ # Get current time (ActiveSupport::Time.current or Time.now fallback)
88
+ def current_time
89
+ if defined?(Time) && Time.respond_to?(:current)
90
+ Time.current
91
+ else
92
+ # rubocop:disable Rails/TimeZone
93
+ # Time.now is intentional fallback for non-Rails usage when Time.current is unavailable
94
+ Time.now
95
+ # rubocop:enable Rails/TimeZone
96
+ end
97
+ end
98
+
99
+ # Check if we're using ActiveSupport::CurrentAttributes
100
+ # This method can be stubbed in tests to test fallback paths
101
+ def using_current_attributes?
102
+ defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
103
+ end
104
+
105
+ # Get inline stylesheets (thread-local fallback if not using CurrentAttributes)
106
+ def inline_stylesheets
107
+ if using_current_attributes?
108
+ # When using CurrentAttributes, access the instance attribute
109
+ # CurrentAttributes automatically provides access to instance attributes
110
+ inst = instance
111
+ inst&.inline_stylesheets || {}
112
+ else
113
+ Thread.current[:style_capsule_inline_stylesheets] ||= {}
114
+ end
115
+ end
116
+
117
+ # Set inline stylesheets (thread-local fallback if not using CurrentAttributes)
118
+ def inline_stylesheets=(value)
119
+ if using_current_attributes?
120
+ # When using CurrentAttributes, set via the instance
121
+ inst = instance
122
+ inst.inline_stylesheets = value if inst
123
+ else
124
+ Thread.current[:style_capsule_inline_stylesheets] = value
125
+ end
126
+ end
127
+
128
+ # Get instance (for CurrentAttributes compatibility)
129
+ def instance
130
+ if using_current_attributes?
131
+ # Call the CurrentAttributes instance method from parent class
132
+ super
133
+ else
134
+ # Return a simple object that responds to inline_stylesheets
135
+ # This is mainly for compatibility with code that might call instance.inline_stylesheets
136
+ registry_class = self
137
+ @_standalone_instance ||= begin
138
+ obj = Object.new
139
+ obj.define_singleton_method(:inline_stylesheets) { registry_class.inline_stylesheets }
140
+ obj.define_singleton_method(:inline_stylesheets=) { |v| registry_class.inline_stylesheets = v }
141
+ obj
142
+ end
143
+ end
144
+ end
145
+
146
+ private
147
+ end
148
+
149
+ # Normalize namespace (nil/blank becomes DEFAULT_NAMESPACE)
150
+ #
151
+ # @param namespace [Symbol, String, nil] Namespace identifier
152
+ # @return [Symbol] Normalized namespace
153
+ def self.normalize_namespace(namespace)
154
+ return DEFAULT_NAMESPACE if namespace.nil? || namespace.to_s.strip.empty?
155
+ namespace.to_sym
156
+ end
157
+
158
+ # Register a stylesheet file path for head rendering
159
+ #
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.
163
+ #
164
+ # 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
+ #
172
+ # @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
173
+ # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
174
+ # @param options [Hash] Options for stylesheet_link_tag
175
+ # @return [void]
176
+ def self.register(file_path, namespace: nil, **options)
177
+ 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
183
+ end
184
+
185
+ # Register inline CSS for head rendering
186
+ #
187
+ # Inline CSS can be cached based on cache configuration. Supports:
188
+ # - No caching (default): stored per-request
189
+ # - Time-based caching: cache expires after TTL
190
+ # - Custom proc caching: use proc to determine cache key and validity
191
+ # - File-based caching: writes CSS to files for HTTP caching
192
+ #
193
+ # @param css_content [String] CSS content (should already be scoped)
194
+ # @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
195
+ # @param capsule_id [String, nil] Optional capsule ID for reference
196
+ # @param cache_key [String, nil] Optional cache key (for cache lookup)
197
+ # @param cache_strategy [Symbol, nil] Cache strategy: :none, :time, :proc, :file (default: :none)
198
+ # @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds (for :time strategy). Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
199
+ # @param cache_proc [Proc, nil] Custom cache proc (for :proc strategy)
200
+ # Proc receives: (css_content, capsule_id, namespace) and should return [cache_key, should_cache, expires_at]
201
+ # @param component_class [Class, nil] Component class (for :file strategy)
202
+ # @param stylesheet_link_options [Hash, nil] Options for stylesheet_link_tag (for :file strategy)
203
+ # @return [void]
204
+ 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
+ ns = normalize_namespace(namespace)
206
+
207
+ # Handle file-based caching (writes to file and registers as file path)
208
+ if cache_strategy == :file && component_class && capsule_id
209
+ # Check if file already exists (from precompilation via assets:precompile or previous write)
210
+ # Pre-built files are already available through Rails asset pipeline and just need to be registered
211
+ existing_path = CssFileWriter.file_path_for(
212
+ component_class: component_class,
213
+ capsule_id: capsule_id
214
+ )
215
+
216
+ if existing_path
217
+ # 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
219
+ # This file will be served through Rails asset pipeline (stylesheet_link_tag)
220
+ link_options = stylesheet_link_options || {}
221
+ register(existing_path, namespace: namespace, **link_options)
222
+ return
223
+ end
224
+
225
+ # File doesn't exist, write it dynamically (development mode or first render)
226
+ # After writing, register it as a file path so it's served as an asset
227
+ file_path = CssFileWriter.write_css(
228
+ css_content: css_content,
229
+ component_class: component_class,
230
+ capsule_id: capsule_id
231
+ )
232
+
233
+ if file_path
234
+ # Register as file path instead of inline CSS
235
+ # This ensures the file is served through Rails asset pipeline
236
+ link_options = stylesheet_link_options || {}
237
+ register(file_path, namespace: namespace, **link_options)
238
+ return
239
+ end
240
+ end
241
+
242
+ # Check cache if strategy is enabled
243
+ cached_css = nil
244
+ if cache_strategy != :none && cache_key && cache_strategy != :file
245
+ cached_css = cached_inline(cache_key, cache_strategy: cache_strategy, cache_ttl: cache_ttl, cache_proc: cache_proc, css_content: css_content, capsule_id: capsule_id, namespace: ns)
246
+ end
247
+
248
+ # Use cached CSS if available, otherwise use provided CSS
249
+ final_css = cached_css || css_content
250
+
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
260
+
261
+ # Cache the CSS if strategy is enabled and not already cached
262
+ if cache_strategy != :none && cache_key && !cached_css && cache_strategy != :file
263
+ 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
+ end
265
+ end
266
+
267
+ # Get cached inline CSS if available and not expired
268
+ #
269
+ # Automatically performs lazy cleanup of expired entries if it's been more than
270
+ # 5 minutes since the last cleanup to prevent memory leaks in long-running processes.
271
+ #
272
+ # @param cache_key [String] Cache key to look up
273
+ # @param cache_strategy [Symbol] Cache strategy
274
+ # @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds. Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
275
+ # @param cache_proc [Proc, nil] Custom cache proc
276
+ # @param css_content [String] Original CSS content (for proc strategy)
277
+ # @param capsule_id [String, nil] Capsule ID (for proc strategy)
278
+ # @param namespace [Symbol] Namespace (for proc strategy)
279
+ # @return [String, nil] Cached CSS content or nil if not cached/expired
280
+ def self.cached_inline(cache_key, cache_strategy:, cache_ttl: nil, cache_proc: nil, css_content: nil, capsule_id: nil, namespace: nil)
281
+ # Lazy cleanup: remove expired entries if it's been a while (every 5 minutes)
282
+ # This prevents memory leaks in long-running processes without impacting performance
283
+ cleanup_expired_cache_if_needed
284
+
285
+ cached_entry = @inline_cache[cache_key]
286
+ return nil unless cached_entry
287
+
288
+ # Check expiration based on strategy
289
+ case cache_strategy
290
+ when :time
291
+ return nil if cache_ttl && cached_entry[:expires_at] && current_time > cached_entry[:expires_at]
292
+ when :proc
293
+ return nil unless cache_proc
294
+ # Proc should validate cache entry
295
+ _key, should_use, _expires = cache_proc.call(css_content, capsule_id, namespace)
296
+ return nil unless should_use
297
+ end
298
+
299
+ cached_entry[:css_content]
300
+ end
301
+
302
+ # Cache inline CSS with expiration
303
+ #
304
+ # @param cache_key [String] Cache key
305
+ # @param css_content [String] CSS content to cache
306
+ # @param cache_strategy [Symbol] Cache strategy
307
+ # @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds. Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
308
+ # @param cache_proc [Proc, nil] Custom cache proc
309
+ # @param capsule_id [String, nil] Capsule ID
310
+ # @param namespace [Symbol] Namespace
311
+ # @return [void]
312
+ def self.cache_inline_css(cache_key, css_content, cache_strategy:, cache_ttl: nil, cache_proc: nil, capsule_id: nil, namespace: nil)
313
+ expires_at = nil
314
+
315
+ case cache_strategy
316
+ when :time
317
+ # Handle ActiveSupport::Duration (e.g., 1.hour) or integer seconds
318
+ ttl_seconds = if cache_ttl.respond_to?(:to_i)
319
+ cache_ttl.to_i
320
+ else
321
+ cache_ttl
322
+ end
323
+ expires_at = ttl_seconds ? current_time + ttl_seconds : nil
324
+ when :proc
325
+ if cache_proc
326
+ _key, _should_cache, proc_expires = cache_proc.call(css_content, capsule_id, namespace)
327
+ expires_at = proc_expires
328
+ end
329
+ end
330
+
331
+ @inline_cache[cache_key] = {
332
+ css_content: css_content,
333
+ cached_at: current_time,
334
+ expires_at: expires_at
335
+ }
336
+ end
337
+
338
+ # Clear inline CSS cache
339
+ #
340
+ # @param cache_key [String, nil] Specific cache key to clear (nil clears all)
341
+ # @return [void]
342
+ def self.clear_inline_cache(cache_key = nil)
343
+ if cache_key
344
+ @inline_cache.delete(cache_key)
345
+ else
346
+ @inline_cache.clear
347
+ end
348
+ end
349
+
350
+ # Clean up expired entries from the inline CSS cache
351
+ #
352
+ # Removes all cache entries that have expired (where expires_at is set and
353
+ # Time.current > expires_at). This prevents memory leaks in long-running processes.
354
+ #
355
+ # This method is called automatically by cached_inline (lazy cleanup every 5 minutes),
356
+ # but can also be called manually for explicit cleanup (e.g., from a background job
357
+ # or scheduled task).
358
+ #
359
+ # @return [Integer] Number of expired entries removed
360
+ # @example Manual cleanup
361
+ # StyleCapsule::StylesheetRegistry.cleanup_expired_cache
362
+ # @example Scheduled cleanup (e.g., in a background job)
363
+ # # In a scheduled job or initializer
364
+ # StyleCapsule::StylesheetRegistry.cleanup_expired_cache
365
+ def self.cleanup_expired_cache
366
+ return 0 if @inline_cache.empty?
367
+
368
+ now = current_time
369
+ expired_keys = []
370
+
371
+ @inline_cache.each do |cache_key, entry|
372
+ # Remove entries that have an expires_at time and it's in the past
373
+ if entry[:expires_at] && now > entry[:expires_at]
374
+ expired_keys << cache_key
375
+ end
376
+ end
377
+
378
+ expired_keys.each { |key| @inline_cache.delete(key) }
379
+ @last_cleanup_time = now
380
+
381
+ expired_keys.size
382
+ end
383
+
384
+ # Check if cleanup is needed and perform it if so
385
+ #
386
+ # Only performs cleanup if it's been more than 5 minutes since the last cleanup.
387
+ # This prevents excessive cleanup calls while still preventing memory leaks.
388
+ #
389
+ # @return [void]
390
+ # @api private
391
+ def self.cleanup_expired_cache_if_needed
392
+ # Cleanup every 5 minutes (300 seconds) to balance memory usage and performance
393
+ cleanup_interval = 300
394
+
395
+ if @last_cleanup_time.nil? || (current_time - @last_cleanup_time) > cleanup_interval
396
+ cleanup_expired_cache
397
+ end
398
+ end
399
+
400
+ private_class_method :cleanup_expired_cache_if_needed
401
+
402
+ # Get all registered file paths from process-wide manifest (organized by namespace)
403
+ #
404
+ # @return [Hash<Symbol, Set<Hash>>] Hash of namespace => set of file registrations
405
+ def self.manifest_files
406
+ @manifest.dup
407
+ end
408
+
409
+ # Get all registered inline stylesheets for current request (organized by namespace)
410
+ #
411
+ # @return [Hash<Symbol, Array<Hash>>] Hash of namespace => array of inline stylesheet registrations
412
+ def self.request_inline_stylesheets
413
+ inline_stylesheets
414
+ end
415
+
416
+ # Get all stylesheets (files + inline) for a specific namespace
417
+ #
418
+ # @param namespace [Symbol, String, nil] Namespace identifier (nil/blank uses default)
419
+ # @return [Array<Hash>] Array of stylesheet registrations for the namespace
420
+ def self.stylesheets_for(namespace: nil)
421
+ 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
428
+
429
+ # Add request-scoped inline CSS
430
+ inline = request_inline_stylesheets[ns] || []
431
+ result.concat(inline)
432
+
433
+ result
434
+ end
435
+
436
+ # Clear request-scoped inline stylesheets (does not clear process-wide manifest)
437
+ #
438
+ # @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
439
+ # @return [void]
440
+ def self.clear(namespace: nil)
441
+ if namespace.nil?
442
+ self.inline_stylesheets = {}
443
+ else
444
+ ns = normalize_namespace(namespace)
445
+ registry = inline_stylesheets
446
+ registry.delete(ns)
447
+ self.inline_stylesheets = registry
448
+ end
449
+ end
450
+
451
+ # Clear process-wide manifest (useful for testing or development reloading)
452
+ #
453
+ # @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
454
+ # @return [void]
455
+ def self.clear_manifest(namespace: nil)
456
+ if namespace.nil?
457
+ @manifest = {}
458
+ else
459
+ ns = normalize_namespace(namespace)
460
+ @manifest.delete(ns)
461
+ end
462
+ end
463
+
464
+ # Render registered stylesheets as HTML
465
+ #
466
+ # 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).
469
+ #
470
+ # @param view_context [ActionView::Base, nil] The view context (for helpers like content_tag, stylesheet_link_tag)
471
+ # In ERB: pass `self` (the view context)
472
+ # In Phlex: pass `view_context` method
473
+ # If nil, falls back to basic HTML generation
474
+ # @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all namespaces)
475
+ # @return [String] HTML-safe string with stylesheet tags
476
+ def self.render_head_stylesheets(view_context = nil, namespace: nil)
477
+ if namespace.nil? || namespace.to_s.strip.empty?
478
+ # Render all namespaces
479
+ all_stylesheets = []
480
+
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
+ request_inline_stylesheets.each do |_ns, inline|
488
+ all_stylesheets.concat(inline)
489
+ end
490
+
491
+ clear # Clear request-scoped inline CSS only
492
+ return safe_string("") if all_stylesheets.empty?
493
+
494
+ all_stylesheets.map do |stylesheet|
495
+ if stylesheet[:type] == :inline
496
+ render_inline_stylesheet(stylesheet, view_context)
497
+ else
498
+ render_file_stylesheet(stylesheet, view_context)
499
+ end
500
+ end.join("\n").then { |s| safe_string(s) }
501
+
502
+ else
503
+ # Render specific namespace
504
+ ns = normalize_namespace(namespace)
505
+ stylesheets = stylesheets_for(namespace: ns).dup
506
+ clear(namespace: ns) # Clear request-scoped inline CSS only
507
+
508
+ return safe_string("") if stylesheets.empty?
509
+
510
+ stylesheets.map do |stylesheet|
511
+ if stylesheet[:type] == :inline
512
+ render_inline_stylesheet(stylesheet, view_context)
513
+ else
514
+ render_file_stylesheet(stylesheet, view_context)
515
+ end
516
+ end.join("\n").then { |s| safe_string(s) }
517
+
518
+ end
519
+ end
520
+
521
+ # Check if there are any registered stylesheets
522
+ #
523
+ # @param namespace [Symbol, String, nil] Optional namespace to check (nil checks all)
524
+ # @return [Boolean]
525
+ def self.any?(namespace: nil)
526
+ if namespace.nil?
527
+ # Check process-wide manifest
528
+ has_files = !@manifest.empty? && @manifest.values.any? { |files| !files.empty? }
529
+ # Check request-scoped inline CSS
530
+ inline = request_inline_stylesheets
531
+ has_inline = !inline.empty? && inline.values.any? { |stylesheets| !stylesheets.empty? }
532
+ has_files || has_inline
533
+ else
534
+ ns = normalize_namespace(namespace)
535
+ # Check process-wide manifest
536
+ has_files = @manifest[ns] && !@manifest[ns].empty?
537
+ # Check request-scoped inline CSS
538
+ has_inline = request_inline_stylesheets[ns] && !request_inline_stylesheets[ns].empty?
539
+ !!(has_files || has_inline)
540
+ end
541
+ end
542
+
543
+ # Render a file-based stylesheet
544
+ def self.render_file_stylesheet(stylesheet, view_context)
545
+ file_path = stylesheet[:file_path]
546
+ options = stylesheet[:options] || {}
547
+
548
+ if view_context&.respond_to?(:stylesheet_link_tag)
549
+ view_context.stylesheet_link_tag(file_path, **options)
550
+ else
551
+ # Fallback if no view context
552
+ href = "/assets/#{file_path}.css"
553
+ tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
554
+ safe_string(%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>))
555
+ end
556
+ end
557
+
558
+ # Render an inline stylesheet
559
+ def self.render_inline_stylesheet(stylesheet, view_context)
560
+ # CSS content is already scoped when registered from components
561
+ # capsule_id is stored for reference but CSS is pre-processed
562
+ css_content = stylesheet[:css_content]
563
+
564
+ # Construct HTML manually to avoid any HTML escaping issues
565
+ # CSS content should not be HTML-escaped as it's inside a <style> tag
566
+ # Using string interpolation with html_safe ensures CSS is not escaped
567
+ safe_string(%(<style type="text/css">#{css_content}</style>))
568
+ end
569
+
570
+ # Make string HTML-safe (compatible with Rails and non-Rails)
571
+ #
572
+ # @param string [String] String to mark as safe
573
+ # @return [String] HTML-safe string
574
+ def self.safe_string(string)
575
+ if string.respond_to?(:html_safe)
576
+ string.html_safe
577
+ else
578
+ string
579
+ end
580
+ end
581
+
582
+ private_class_method :render_file_stylesheet, :render_inline_stylesheet, :safe_string
583
+ end
584
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ VERSION = "1.2.0"
5
+ end