style_capsule 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
3
4
  require_relative "instrumentation"
4
5
  require_relative "asset_path"
6
+ require_relative "css_processor"
5
7
 
6
8
  module StyleCapsule
7
9
  # Helper to determine the parent class for StylesheetRegistry
@@ -72,14 +74,18 @@ module StyleCapsule
72
74
  class StylesheetRegistry < StyleCapsule.stylesheet_registry_parent_class
73
75
  # Default namespace for backward compatibility
74
76
  DEFAULT_NAMESPACE = :default
77
+ MAX_INLINE_CACHE_ENTRIES = 256
78
+ FALLBACK_LINK_OPTION_NAME = /\A(?:media|title|crossorigin|integrity|nonce|type|id|class|data-[a-zA-Z0-9_-]+)\z/
75
79
 
76
80
  # Process-wide manifest for static file paths (like Propshaft)
77
81
  # Organized by namespace: { namespace => { logical_path => { file_path:, options: } } }
78
82
  @manifest = {} # rubocop:disable Style/ClassVars, ThreadSafety/MutableClassInstanceVariable
83
+ @manifest_mutex = Mutex.new
79
84
 
80
85
  # Process-wide cache for inline CSS (with expiration support)
81
86
  # Structure: { cache_key => { css_content: String, cached_at: Time, expires_at: Time } }
82
87
  @inline_cache = {} # rubocop:disable Style/ClassVars, ThreadSafety/MutableClassInstanceVariable
88
+ @inline_cache_mutex = Mutex.new
83
89
 
84
90
  # Track last cleanup time for lazy cleanup (prevents excessive cleanup calls)
85
91
  @last_cleanup_time = nil # rubocop:disable Style/ClassVars
@@ -222,8 +228,10 @@ module StyleCapsule
222
228
  inline_size: nil,
223
229
  cache_strategy: :none
224
230
  ) do
225
- @manifest[ns] ||= {}
226
- @manifest[ns][path] = {file_path: path, options: options}
231
+ with_manifest do
232
+ @manifest[ns] ||= {}
233
+ @manifest[ns][path] = {file_path: path, options: options}
234
+ end
227
235
  end
228
236
  end
229
237
 
@@ -350,11 +358,10 @@ module StyleCapsule
350
358
  # @param namespace [Symbol] Namespace (for proc strategy)
351
359
  # @return [String, nil] Cached CSS content or nil if not cached/expired
352
360
  def self.cached_inline(cache_key, cache_strategy:, cache_ttl: nil, cache_proc: nil, css_content: nil, capsule_id: nil, namespace: nil)
353
- # Lazy cleanup: remove expired entries if it's been a while (every 5 minutes)
354
- # This prevents memory leaks in long-running processes without impacting performance
355
- cleanup_expired_cache_if_needed
356
-
357
- cached_entry = @inline_cache[cache_key]
361
+ cached_entry = with_inline_cache do
362
+ cleanup_expired_cache_unlocked_if_needed
363
+ @inline_cache[cache_key]
364
+ end
358
365
  return nil unless cached_entry
359
366
 
360
367
  # Check expiration based on strategy
@@ -402,11 +409,16 @@ module StyleCapsule
402
409
  end
403
410
  end
404
411
 
405
- @inline_cache[cache_key] = {
406
- css_content: css_content,
407
- cached_at: current_time,
408
- expires_at: expires_at
409
- }
412
+ with_inline_cache do
413
+ unless @inline_cache.key?(cache_key)
414
+ @inline_cache.shift while @inline_cache.size >= MAX_INLINE_CACHE_ENTRIES
415
+ end
416
+ @inline_cache[cache_key] = {
417
+ css_content: css_content,
418
+ cached_at: current_time,
419
+ expires_at: expires_at
420
+ }
421
+ end
410
422
  end
411
423
 
412
424
  # Clear inline CSS cache
@@ -414,10 +426,12 @@ module StyleCapsule
414
426
  # @param cache_key [String, nil] Specific cache key to clear (nil clears all)
415
427
  # @return [void]
416
428
  def self.clear_inline_cache(cache_key = nil)
417
- if cache_key
418
- @inline_cache.delete(cache_key)
419
- else
420
- @inline_cache.clear
429
+ with_inline_cache do
430
+ if cache_key
431
+ @inline_cache.delete(cache_key)
432
+ else
433
+ @inline_cache.clear
434
+ end
421
435
  end
422
436
  end
423
437
 
@@ -437,13 +451,16 @@ module StyleCapsule
437
451
  # # In a scheduled job or initializer
438
452
  # StyleCapsule::StylesheetRegistry.cleanup_expired_cache
439
453
  def self.cleanup_expired_cache
454
+ with_inline_cache { cleanup_expired_cache_unlocked }
455
+ end
456
+
457
+ def self.cleanup_expired_cache_unlocked
440
458
  return 0 if @inline_cache.empty?
441
459
 
442
460
  now = current_time
443
461
  expired_keys = []
444
462
 
445
463
  @inline_cache.each do |cache_key, entry|
446
- # Remove entries that have an expires_at time and it's in the past
447
464
  if entry[:expires_at] && now > entry[:expires_at]
448
465
  expired_keys << cache_key
449
466
  end
@@ -455,23 +472,24 @@ module StyleCapsule
455
472
  expired_keys.size
456
473
  end
457
474
 
458
- # Check if cleanup is needed and perform it if so
459
- #
460
- # Only performs cleanup if it's been more than 5 minutes since the last cleanup.
461
- # This prevents excessive cleanup calls while still preventing memory leaks.
462
- #
463
- # @return [void]
464
- # @api private
465
- def self.cleanup_expired_cache_if_needed
466
- # Cleanup every 5 minutes (300 seconds) to balance memory usage and performance
475
+ def self.cleanup_expired_cache_unlocked_if_needed
467
476
  cleanup_interval = 300
468
477
 
469
478
  if @last_cleanup_time.nil? || (current_time - @last_cleanup_time) > cleanup_interval
470
- cleanup_expired_cache
479
+ cleanup_expired_cache_unlocked
471
480
  end
472
481
  end
473
482
 
474
- private_class_method :cleanup_expired_cache_if_needed
483
+ def self.with_inline_cache
484
+ @inline_cache_mutex.synchronize { yield }
485
+ end
486
+
487
+ def self.with_manifest
488
+ @manifest_mutex.synchronize { yield }
489
+ end
490
+
491
+ private_class_method :cleanup_expired_cache_unlocked, :cleanup_expired_cache_unlocked_if_needed,
492
+ :with_inline_cache, :with_manifest
475
493
 
476
494
  # Get all registered file paths from process-wide manifest (organized by namespace)
477
495
  #
@@ -540,11 +558,13 @@ module StyleCapsule
540
558
  # @param namespace [Symbol, String, nil] Optional namespace to clear (nil clears all)
541
559
  # @return [void]
542
560
  def self.clear_manifest(namespace: nil)
543
- if namespace.nil?
544
- @manifest = {}
545
- else
546
- ns = normalize_namespace(namespace)
547
- @manifest.delete(ns)
561
+ with_manifest do
562
+ if namespace.nil?
563
+ @manifest = {}
564
+ else
565
+ ns = normalize_namespace(namespace)
566
+ @manifest.delete(ns)
567
+ end
548
568
  end
549
569
  end
550
570
 
@@ -564,7 +584,17 @@ module StyleCapsule
564
584
  # If nil, falls back to basic HTML generation
565
585
  # @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all namespaces)
566
586
  # @return [String] HTML-safe string with stylesheet tags
567
- # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity -- renders mixed inline and file registrations
587
+ def self.render_stylesheet_entries(stylesheets, view_context)
588
+ stylesheets.map do |stylesheet|
589
+ if stylesheet[:type] == :inline
590
+ render_inline_stylesheet(stylesheet, view_context)
591
+ else
592
+ render_file_stylesheet(stylesheet, view_context)
593
+ end
594
+ end.join("\n").then { |s| safe_string(s) }
595
+ end
596
+
597
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- renders mixed inline and file registrations
568
598
  def self.render_head_stylesheets(view_context = nil, namespace: nil)
569
599
  if namespace.nil? || namespace.to_s.strip.empty?
570
600
  request_files_snapshot = request_file_stylesheets.transform_values { |files| files.keys.dup }
@@ -578,16 +608,9 @@ module StyleCapsule
578
608
 
579
609
  return safe_string("") if all_stylesheets.empty?
580
610
 
581
- rendered = all_stylesheets.map do |stylesheet|
582
- if stylesheet[:type] == :inline
583
- render_inline_stylesheet(stylesheet, view_context)
584
- else
585
- render_file_stylesheet(stylesheet, view_context)
586
- end
587
- end.join("\n").then { |s| safe_string(s) }
611
+ rendered = render_stylesheet_entries(all_stylesheets, view_context)
588
612
 
589
613
  clear_snapshotted_request_registrations(request_files_snapshot, request_inline_snapshot)
590
- rendered
591
614
 
592
615
  else
593
616
  # Render specific namespace
@@ -598,24 +621,19 @@ module StyleCapsule
598
621
 
599
622
  return safe_string("") if stylesheets.empty?
600
623
 
601
- rendered = stylesheets.map do |stylesheet|
602
- if stylesheet[:type] == :inline
603
- render_inline_stylesheet(stylesheet, view_context)
604
- else
605
- render_file_stylesheet(stylesheet, view_context)
606
- end
607
- end.join("\n").then { |s| safe_string(s) }
624
+ rendered = render_stylesheet_entries(stylesheets, view_context)
608
625
 
609
626
  clear_snapshotted_request_registrations_for_namespace(
610
627
  ns,
611
628
  file_paths: snapshotted_file_paths,
612
629
  snapshotted_inline_stylesheets: snapshotted_inline
613
630
  )
614
- rendered
615
631
 
616
632
  end
633
+
634
+ rendered
617
635
  end
618
- # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
636
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
619
637
 
620
638
  # Check if there are any registered stylesheets
621
639
  #
@@ -668,6 +686,7 @@ module StyleCapsule
668
686
  end
669
687
 
670
688
  # @api private
689
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- clears snapshotted request registries per namespace
671
690
  def self.clear_snapshotted_request_registrations_for_namespace(namespace, file_paths:, snapshotted_inline_stylesheets:)
672
691
  ns = normalize_namespace(namespace)
673
692
 
@@ -694,6 +713,7 @@ module StyleCapsule
694
713
 
695
714
  self.inline_stylesheets = inline_registry
696
715
  end
716
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
697
717
 
698
718
  # Inject pending request-scoped stylesheets into an HTML document before +</head>+.
699
719
  #
@@ -708,10 +728,16 @@ module StyleCapsule
708
728
  return html if pending_stylesheets.empty?
709
729
 
710
730
  closing_head_index = html.match(%r{</head>}i)&.begin(0)
711
- return html unless closing_head_index
731
+ unless closing_head_index
732
+ clear
733
+ return html
734
+ end
712
735
 
713
736
  tags = render_stylesheet_tags(pending_stylesheets, view_context)
714
- return html if tags.empty?
737
+ if tags.empty?
738
+ clear
739
+ return html
740
+ end
715
741
 
716
742
  injected = html.dup
717
743
  injected.insert(closing_head_index, "#{tags}\n")
@@ -793,20 +819,24 @@ module StyleCapsule
793
819
  else
794
820
  # Fallback if no view context (logical path validated in .register)
795
821
  href = "/assets/#{file_path}.css"
796
- tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
822
+ tag_options = fallback_link_option_attributes(options)
797
823
  safe_string(%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>))
798
824
  end
799
825
  end
800
826
 
827
+ def self.fallback_link_option_attributes(options)
828
+ options.filter_map do |key, value|
829
+ name = key.to_s
830
+ next unless name.match?(FALLBACK_LINK_OPTION_NAME)
831
+
832
+ %(#{name}="#{CGI.escapeHTML(value.to_s)}")
833
+ end.join(" ")
834
+ end
835
+
801
836
  # Render an inline stylesheet
802
- def self.render_inline_stylesheet(stylesheet, view_context)
803
- # CSS content is already scoped when registered from components
804
- # capsule_id is stored for reference but CSS is pre-processed
837
+ def self.render_inline_stylesheet(stylesheet, _view_context)
805
838
  css_content = stylesheet[:css_content]
806
-
807
- # Construct HTML manually to avoid any HTML escaping issues
808
- # CSS content should not be HTML-escaped as it's inside a <style> tag
809
- # Using string interpolation with html_safe ensures CSS is not escaped
839
+ CssProcessor.reject_style_element_breakout!(css_content)
810
840
  safe_string(%(<style type="text/css">#{css_content}</style>))
811
841
  end
812
842
 
@@ -822,6 +852,7 @@ module StyleCapsule
822
852
  end
823
853
  end
824
854
 
825
- private_class_method :render_file_stylesheet, :render_inline_stylesheet, :safe_string
855
+ private_class_method :render_file_stylesheet, :render_inline_stylesheet, :safe_string,
856
+ :fallback_link_option_attributes
826
857
  end
827
858
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StyleCapsule
4
- VERSION = "2.0.1"
4
+ VERSION = "2.0.3"
5
5
  end
@@ -167,7 +167,7 @@ module StyleCapsule
167
167
 
168
168
  # File caching is only allowed for class method component_styles
169
169
  if cache_strategy == :file && !file_caching_allowed?
170
- # Fall back to :none strategy if file caching requested but not allowed
170
+ warn_file_cache_fallback(self.class)
171
171
  cache_strategy = :none
172
172
  cache_ttl = nil
173
173
  cache_proc = nil
@@ -6,9 +6,13 @@ module StyleCapsule
6
6
  end
7
7
 
8
8
  module CssProcessor
9
+ MAX_CSS_SIZE: Integer
10
+ NON_SCOPING_AT_RULES: Array[String]
11
+ HOISTABLE_AT_RULES: Array[String]
12
+
9
13
  def self.scope_selectors: (String css_string, String capsule_id, ?component_class: Class | String | nil) -> String
10
14
  def self.scope_with_nesting: (String css_string, String capsule_id, ?component_class: Class | String | nil) -> String
11
- def self.strip_comments: (String css) -> String
15
+ def self.reject_style_element_breakout!: (String | nil css_string) -> void
12
16
  end
13
17
 
14
18
  class CssFileWriter
@@ -24,6 +28,7 @@ module StyleCapsule
24
28
  # At runtime inherits Object when ActiveSupport::CurrentAttributes is unavailable.
25
29
  class StylesheetRegistry
26
30
  DEFAULT_NAMESPACE: Symbol
31
+ MAX_INLINE_CACHE_ENTRIES: Integer
27
32
 
28
33
  def self.normalize_namespace: (Symbol | String | nil namespace) -> Symbol
29
34
  def self.register: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: style_capsule
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.1
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.0'
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '9.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: '6.0'
28
+ version: '7.0'
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
@@ -35,7 +35,7 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '6.0'
38
+ version: '7.0'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '9.0'
@@ -45,7 +45,7 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '6.0'
48
+ version: '7.0'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
@@ -67,16 +67,16 @@ dependencies:
67
67
  name: polyrun
68
68
  requirement: !ruby/object:Gem::Requirement
69
69
  requirements:
70
- - - "~>"
70
+ - - ">="
71
71
  - !ruby/object:Gem::Version
72
- version: 1.5.0
72
+ version: 2.2.0
73
73
  type: :development
74
74
  prerelease: false
75
75
  version_requirements: !ruby/object:Gem::Requirement
76
76
  requirements:
77
- - - "~>"
77
+ - - ">="
78
78
  - !ruby/object:Gem::Version
79
- version: 1.5.0
79
+ version: 2.2.0
80
80
  - !ruby/object:Gem::Dependency
81
81
  name: webmock
82
82
  requirement: !ruby/object:Gem::Requirement
@@ -105,48 +105,6 @@ dependencies:
105
105
  - - "~>"
106
106
  - !ruby/object:Gem::Version
107
107
  version: '13'
108
- - !ruby/object:Gem::Dependency
109
- name: simplecov
110
- requirement: !ruby/object:Gem::Requirement
111
- requirements:
112
- - - "~>"
113
- - !ruby/object:Gem::Version
114
- version: '0.22'
115
- type: :development
116
- prerelease: false
117
- version_requirements: !ruby/object:Gem::Requirement
118
- requirements:
119
- - - "~>"
120
- - !ruby/object:Gem::Version
121
- version: '0.22'
122
- - !ruby/object:Gem::Dependency
123
- name: rspec_junit_formatter
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - "~>"
127
- - !ruby/object:Gem::Version
128
- version: '0.6'
129
- type: :development
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - "~>"
134
- - !ruby/object:Gem::Version
135
- version: '0.6'
136
- - !ruby/object:Gem::Dependency
137
- name: simplecov-cobertura
138
- requirement: !ruby/object:Gem::Requirement
139
- requirements:
140
- - - "~>"
141
- - !ruby/object:Gem::Version
142
- version: '3'
143
- type: :development
144
- prerelease: false
145
- version_requirements: !ruby/object:Gem::Requirement
146
- requirements:
147
- - - "~>"
148
- - !ruby/object:Gem::Version
149
- version: '3'
150
108
  - !ruby/object:Gem::Dependency
151
109
  name: standard
152
110
  requirement: !ruby/object:Gem::Requirement
@@ -273,20 +231,6 @@ dependencies:
273
231
  - - "~>"
274
232
  - !ruby/object:Gem::Version
275
233
  version: '2'
276
- - !ruby/object:Gem::Dependency
277
- name: memory_profiler
278
- requirement: !ruby/object:Gem::Requirement
279
- requirements:
280
- - - "~>"
281
- - !ruby/object:Gem::Version
282
- version: '1'
283
- type: :development
284
- prerelease: false
285
- version_requirements: !ruby/object:Gem::Requirement
286
- requirements:
287
- - - "~>"
288
- - !ruby/object:Gem::Version
289
- version: '1'
290
234
  - !ruby/object:Gem::Dependency
291
235
  name: rbs
292
236
  requirement: !ruby/object:Gem::Requirement
@@ -329,11 +273,8 @@ dependencies:
329
273
  - - "~>"
330
274
  - !ruby/object:Gem::Version
331
275
  version: '4.0'
332
- description: Provides component-scoped CSS encapsulation using [data-capsule] attributes
333
- for Phlex components, ViewComponent components, and ERB templates. Styles are automatically
334
- scoped to prevent leakage between components. Inspired by component-based CSS approaches
335
- like Angular's view encapsulation and CSS modules. Works with Rails and can be used
336
- standalone in other Ruby frameworks (Sinatra, Hanami, etc.) or plain Ruby scripts.
276
+ description: Attribute-based CSS scoping via [data-capsule] for Phlex, ViewComponent,
277
+ and ERB. Works with Rails and standalone in other Ruby frameworks or plain Ruby.
337
278
  email:
338
279
  - contact@kiskolabs.com
339
280
  executables: []
@@ -344,7 +285,16 @@ files:
344
285
  - LICENSE.md
345
286
  - README.md
346
287
  - SECURITY.md
288
+ - docs/changelogs/20260904180000_audit-fixes.md
289
+ - docs/changelogs/20260925094000_head-injection-bufferable-chunked.md
290
+ - docs/changelogs/20260928193000_sync-appraisal-locks-after-version-bump.md
291
+ - docs/dependencies/20260904174500_json-cve-2026-71847.md
292
+ - docs/issues/20260904174500_engineering-and-dependency-audit.md
293
+ - docs/issues/20260917123253_ci-drop-automatic-dependency-audit.md
294
+ - docs/issues/20261103150931_engineering-prose-release-audit.md
347
295
  - docs/non_rails_support.md
296
+ - docs/reviews/20261028_engineering-audit-release-preparation.md
297
+ - docs/reviews/20261103150931_engineering-prose-release-audit.md
348
298
  - lib/style_capsule.rb
349
299
  - lib/style_capsule/asset_path.rb
350
300
  - lib/style_capsule/class_registry.rb
@@ -383,14 +333,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
383
333
  requirements:
384
334
  - - ">="
385
335
  - !ruby/object:Gem::Version
386
- version: '3.0'
336
+ version: '3.4'
387
337
  required_rubygems_version: !ruby/object:Gem::Requirement
388
338
  requirements:
389
339
  - - ">="
390
340
  - !ruby/object:Gem::Version
391
341
  version: '0'
392
342
  requirements: []
393
- rubygems_version: 4.0.3
343
+ rubygems_version: 4.0.6
394
344
  specification_version: 4
395
345
  summary: Attribute-based CSS scoping for Phlex, ViewComponent, and ERB templates.
396
346
  test_files: []