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,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Registry for tracking classes that include StyleCapsule modules
5
+ #
6
+ # This provides a Rails-friendly way to track classes without using ObjectSpace,
7
+ # which can be problematic with certain gems (e.g., Faker) that override Class#name.
8
+ #
9
+ # Classes are automatically registered when they include StyleCapsule::Component
10
+ # or StyleCapsule::ViewComponent.
11
+ #
12
+ # @example
13
+ # # Classes are automatically registered when they include modules
14
+ # class MyComponent < ApplicationComponent
15
+ # include StyleCapsule::Component # Automatically registered
16
+ # end
17
+ #
18
+ # # Iterate over registered classes
19
+ # StyleCapsule::ClassRegistry.each do |klass|
20
+ # klass.clear_css_cache if klass.respond_to?(:clear_css_cache)
21
+ # end
22
+ #
23
+ # # Clear registry (useful in development when classes are reloaded)
24
+ # StyleCapsule::ClassRegistry.clear
25
+ class ClassRegistry
26
+ # Use Set for O(1) lookups instead of O(n) with Array#include?
27
+ @classes = Set.new
28
+
29
+ class << self
30
+ # Register a class that includes a StyleCapsule module
31
+ #
32
+ # @param klass [Class] The class to register
33
+ # @return [void]
34
+ def register(klass)
35
+ return if klass.nil?
36
+ return if klass.singleton_class?
37
+
38
+ # Only register classes with names (skip anonymous classes)
39
+ begin
40
+ name = klass.name
41
+ return if name.nil? || name.to_s.strip.empty?
42
+ rescue
43
+ # Skip classes that cause errors when calling name (e.g., ArgumentError, NoMethodError, NameError)
44
+ return
45
+ end
46
+
47
+ @classes.add(klass)
48
+ end
49
+
50
+ # Remove a class from the registry
51
+ #
52
+ # @param klass [Class] The class to unregister
53
+ # @return [void]
54
+ def unregister(klass)
55
+ @classes.delete(klass)
56
+ end
57
+
58
+ # Iterate over all registered classes
59
+ #
60
+ # @yield [Class] Each registered class
61
+ # @return [void]
62
+ def each(&block)
63
+ # Filter out classes that no longer exist or have been unloaded
64
+ # Use delete_if for Set (equivalent to reject! for Array)
65
+ @classes.delete_if do |klass|
66
+ # Check if class still exists and is valid
67
+ klass.name.nil? || klass.singleton_class?
68
+ rescue
69
+ # Class has been unloaded or causes errors - remove from registry
70
+ true
71
+ end
72
+
73
+ @classes.each(&block)
74
+ end
75
+
76
+ # Get all registered classes
77
+ #
78
+ # @return [Array<Class>] Array of registered classes
79
+ def all
80
+ # Filter out invalid classes
81
+ each.to_a
82
+ end
83
+
84
+ # Clear the registry
85
+ #
86
+ # Useful in development when classes are reloaded.
87
+ #
88
+ # @return [void]
89
+ def clear
90
+ @classes.clear
91
+ end
92
+
93
+ # Get the number of registered classes
94
+ #
95
+ # @return [Integer]
96
+ def count
97
+ each.count
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ # ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
5
+
6
+ module StyleCapsule
7
+ # Phlex component concern for encapsulated CSS
8
+ #
9
+ # This implements attribute-based CSS scoping for component encapsulation:
10
+ # - CSS selectors are rewritten to include [data-capsule="..."] attribute selectors
11
+ # - Class names remain unchanged (no renaming)
12
+ # - Scope ID is per-component-type (shared across all instances)
13
+ # - Styles are rendered as <style> tag in body before component HTML
14
+ # - Automatically wraps component content in a scoped wrapper element
15
+ #
16
+ # Usage in a Phlex component:
17
+ #
18
+ # Instance method (dynamic rendering, all cache strategies except :file):
19
+ #
20
+ # class MyComponent < ApplicationComponent
21
+ # include StyleCapsule::Component
22
+ #
23
+ # def component_styles
24
+ # <<~CSS
25
+ # .section {
26
+ # color: red;
27
+ # }
28
+ # .heading:hover {
29
+ # opacity: 0.8;
30
+ # }
31
+ # CSS
32
+ # end
33
+ # end
34
+ #
35
+ # Class method (static rendering, supports all cache strategies including :file):
36
+ #
37
+ # class MyComponent < ApplicationComponent
38
+ # include StyleCapsule::Component
39
+ # stylesheet_registry cache_strategy: :file # File caching requires class method
40
+ #
41
+ # def self.component_styles
42
+ # <<~CSS
43
+ # .section {
44
+ # color: red;
45
+ # }
46
+ # CSS
47
+ # end
48
+ #
49
+ # def view_template
50
+ # # Content is automatically wrapped in a scoped element
51
+ # # No need to manually add data-capsule attribute!
52
+ # div(class: "section") do
53
+ # h2(class: "heading") { "Hello" }
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # For testing with a custom scope ID:
59
+ #
60
+ # class MyComponent < ApplicationComponent
61
+ # include StyleCapsule::Component
62
+ # capsule_id "test-capsule-123" # Use exact capsule ID for testing
63
+ #
64
+ # def component_styles
65
+ # <<~CSS
66
+ # .section { color: red; }
67
+ # CSS
68
+ # end
69
+ # end
70
+ #
71
+ # The CSS will be automatically rewritten from:
72
+ # .section { color: red; }
73
+ # .heading:hover { opacity: 0.8; }
74
+ #
75
+ # To:
76
+ # [data-capsule="a1b2c3d4"] .section { color: red; }
77
+ # [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
78
+ #
79
+ # And the HTML will be automatically wrapped:
80
+ # <div data-capsule="a1b2c3d4">
81
+ # <div class="section">...</div>
82
+ # </div>
83
+ #
84
+ # This ensures styles only apply to elements within the scoped component.
85
+ module Component
86
+ def self.included(base)
87
+ base.extend(ClassMethods)
88
+ base.include(ComponentStylesSupport)
89
+
90
+ # Use prepend to wrap view_template method
91
+ base.prepend(ViewTemplateWrapper)
92
+
93
+ # Register class for Rails-friendly tracking
94
+ ClassRegistry.register(base)
95
+ end
96
+
97
+ module ClassMethods
98
+ # Class-level cache for scoped CSS per component class
99
+ def css_cache
100
+ @css_cache ||= {}
101
+ end
102
+
103
+ # Clear the CSS cache for this component class
104
+ #
105
+ # Useful for testing or when you want to force CSS reprocessing.
106
+ # In development, this is automatically called when classes are reloaded.
107
+ #
108
+ # @example
109
+ # MyComponent.clear_css_cache
110
+ def clear_css_cache
111
+ @css_cache = {}
112
+ end
113
+
114
+ # Set or get a custom capsule ID for this component class (useful for testing)
115
+ #
116
+ # @param capsule_id [String, nil] The custom capsule ID to use (nil to get current value)
117
+ # @return [String, nil] The current capsule ID if no argument provided
118
+ # @example Setting a custom capsule ID
119
+ # class MyComponent < ApplicationComponent
120
+ # include StyleCapsule::Component
121
+ # capsule_id "test-capsule-123"
122
+ # end
123
+ # @example Getting the current capsule ID
124
+ # MyComponent.capsule_id # => "test-capsule-123" or nil
125
+ def capsule_id(capsule_id = nil)
126
+ if capsule_id.nil?
127
+ @custom_capsule_id if defined?(@custom_capsule_id)
128
+ else
129
+ @custom_capsule_id = capsule_id.to_s
130
+ end
131
+ end
132
+
133
+ # Configure stylesheet registry for head rendering
134
+ #
135
+ # Enables head rendering and configures namespace and cache strategy in a single call.
136
+ # All parameters are optional - calling without arguments enables head rendering with defaults.
137
+ #
138
+ # @param namespace [Symbol, String, nil] Namespace identifier (nil/blank uses default)
139
+ # @param cache_strategy [Symbol, String, Proc, nil] Cache strategy: :none (default), :time, :proc, :file
140
+ # - Symbol or String: :none, :time, :proc, :file (or "none", "time", "proc", "file")
141
+ # - Proc: Custom cache proc (automatically uses :proc strategy)
142
+ # Proc receives: (css_content, capsule_id, namespace) and should return [cache_key, should_cache, expires_at]
143
+ # @param cache_ttl [Integer, ActiveSupport::Duration, nil] Time-to-live in seconds (for :time strategy). Supports ActiveSupport::Duration (e.g., 1.hour, 30.minutes)
144
+ # @param cache_proc [Proc, nil] Custom cache proc (for :proc strategy, ignored if cache_strategy is a Proc)
145
+ # Proc receives: (css_content, capsule_id, namespace) and should return [cache_key, should_cache, expires_at]
146
+ # @return [void]
147
+ # @example Basic usage (enables head rendering with defaults)
148
+ # class MyComponent < ApplicationComponent
149
+ # include StyleCapsule::Component
150
+ # stylesheet_registry
151
+ # end
152
+ # @example With namespace
153
+ # class AdminComponent < ApplicationComponent
154
+ # include StyleCapsule::Component
155
+ # stylesheet_registry namespace: :admin
156
+ # end
157
+ # @example With time-based caching
158
+ # class MyComponent < ApplicationComponent
159
+ # include StyleCapsule::Component
160
+ # stylesheet_registry cache_strategy: :time, cache_ttl: 1.hour
161
+ # end
162
+ # @example With custom proc caching
163
+ # class MyComponent < ApplicationComponent
164
+ # include StyleCapsule::Component
165
+ # stylesheet_registry cache_strategy: :proc, cache_proc: ->(css, capsule_id, ns) {
166
+ # cache_key = "css_#{capsule_id}_#{ns}"
167
+ # should_cache = css.length > 100
168
+ # expires_at = Time.now + 1800
169
+ # [cache_key, should_cache, expires_at]
170
+ # }
171
+ # end
172
+ # @example File-based caching (requires class method component_styles)
173
+ # class MyComponent < ApplicationComponent
174
+ # include StyleCapsule::Component
175
+ # stylesheet_registry cache_strategy: :file
176
+ #
177
+ # def self.component_styles # Must be class method for file caching
178
+ # <<~CSS
179
+ # .section { color: red; }
180
+ # CSS
181
+ # end
182
+ # end
183
+ # @example All options combined
184
+ # class MyComponent < ApplicationComponent
185
+ # include StyleCapsule::Component
186
+ # stylesheet_registry namespace: :admin, cache_strategy: :time, cache_ttl: 1.hour
187
+ # end
188
+ def stylesheet_registry(namespace: nil, cache_strategy: :none, cache_ttl: nil, cache_proc: nil)
189
+ @head_rendering = true
190
+ @stylesheet_namespace = namespace unless namespace.nil?
191
+
192
+ # Normalize cache_strategy: convert strings to symbols, handle Proc
193
+ normalized_strategy, normalized_proc = normalize_cache_strategy(cache_strategy, cache_proc)
194
+ @inline_cache_strategy = normalized_strategy
195
+ @inline_cache_ttl = cache_ttl
196
+ @inline_cache_proc = normalized_proc
197
+ end
198
+
199
+ private
200
+
201
+ # Normalize cache_strategy to handle Symbol, String, and Proc
202
+ #
203
+ # @param cache_strategy [Symbol, String, Proc, nil] Cache strategy
204
+ # @param cache_proc [Proc, nil] Optional cache proc (ignored if cache_strategy is a Proc)
205
+ # @return [Array<Symbol, Proc|nil>] Normalized strategy and proc
206
+ def normalize_cache_strategy(cache_strategy, cache_proc)
207
+ case cache_strategy
208
+ when Proc
209
+ # If cache_strategy is a Proc, use it as the proc and set strategy to :proc
210
+ [:proc, cache_strategy]
211
+ when String
212
+ # Convert string to symbol
213
+ normalized = cache_strategy.to_sym
214
+ unless [:none, :time, :proc, :file].include?(normalized)
215
+ raise ArgumentError, "cache_strategy must be :none, :time, :proc, or :file (got: #{cache_strategy.inspect})"
216
+ end
217
+ [normalized, cache_proc]
218
+ when Symbol
219
+ unless [:none, :time, :proc, :file].include?(cache_strategy)
220
+ raise ArgumentError, "cache_strategy must be :none, :time, :proc, or :file (got: #{cache_strategy.inspect})"
221
+ end
222
+ [cache_strategy, cache_proc]
223
+ when nil
224
+ [:none, nil]
225
+ else
226
+ raise ArgumentError, "cache_strategy must be a Symbol, String, or Proc (got: #{cache_strategy.class})"
227
+ end
228
+ end
229
+
230
+ # Deprecated: Use stylesheet_registry instead
231
+ # @deprecated Use {#stylesheet_registry} instead
232
+ def head_rendering!
233
+ stylesheet_registry
234
+ end
235
+
236
+ # Check if component uses head rendering
237
+ def head_rendering?
238
+ return false unless defined?(@head_rendering)
239
+ @head_rendering
240
+ end
241
+
242
+ public :head_rendering?
243
+
244
+ # Get the namespace for stylesheet registry
245
+ def stylesheet_namespace
246
+ @stylesheet_namespace if defined?(@stylesheet_namespace)
247
+ end
248
+
249
+ # Get the custom scope ID if set (alias for capsule_id getter)
250
+ def custom_capsule_id
251
+ @custom_capsule_id if defined?(@custom_capsule_id)
252
+ end
253
+
254
+ # Get inline cache strategy
255
+ def inline_cache_strategy
256
+ @inline_cache_strategy if defined?(@inline_cache_strategy)
257
+ end
258
+
259
+ # Get inline cache TTL
260
+ def inline_cache_ttl
261
+ @inline_cache_ttl if defined?(@inline_cache_ttl)
262
+ end
263
+
264
+ # Get inline cache proc
265
+ def inline_cache_proc
266
+ @inline_cache_proc if defined?(@inline_cache_proc)
267
+ end
268
+
269
+ public :head_rendering?, :stylesheet_namespace, :custom_capsule_id, :inline_cache_strategy, :inline_cache_ttl, :inline_cache_proc
270
+
271
+ # Set or get options for stylesheet_link_tag when using file-based caching
272
+ #
273
+ # @param options [Hash, nil] Options to pass to stylesheet_link_tag (e.g., "data-turbo-track": "reload", omit to get current value)
274
+ # @return [Hash, nil] The current stylesheet link options if no argument provided
275
+ # @example Setting stylesheet link options
276
+ # class MyComponent < ApplicationComponent
277
+ # include StyleCapsule::Component
278
+ # stylesheet_registry cache_strategy: :file
279
+ # stylesheet_link_options "data-turbo-track": "reload"
280
+ # end
281
+ # @example Getting the current options
282
+ # MyComponent.stylesheet_link_options # => {"data-turbo-track" => "reload"} or nil
283
+ def stylesheet_link_options(options = nil)
284
+ if options.nil?
285
+ @stylesheet_link_options if defined?(@stylesheet_link_options)
286
+ else
287
+ @stylesheet_link_options = options
288
+ end
289
+ end
290
+
291
+ public :stylesheet_link_options
292
+
293
+ # Set or get CSS scoping strategy
294
+ #
295
+ # @param strategy [Symbol, nil] Scoping strategy: :selector_patching (default) or :nesting (omit to get current value)
296
+ # - :selector_patching: Adds [data-capsule="..."] prefix to each selector (better browser support)
297
+ # - :nesting: Wraps entire CSS in [data-capsule="..."] { ... } (more performant, requires CSS nesting support)
298
+ # @return [Symbol] The current scoping strategy (default: :selector_patching)
299
+ # @example Using CSS nesting (requires Chrome 112+, Firefox 117+, Safari 16.5+)
300
+ # class MyComponent < ApplicationComponent
301
+ # include StyleCapsule::Component
302
+ # css_scoping_strategy :nesting # More performant, no CSS parsing needed
303
+ #
304
+ # def component_styles
305
+ # <<~CSS
306
+ # .section { color: red; }
307
+ # .heading:hover { opacity: 0.8; }
308
+ # CSS
309
+ # end
310
+ # end
311
+ # # Output: [data-capsule="abc123"] { .section { color: red; } .heading:hover { opacity: 0.8; } }
312
+ # @example Using selector patching (default, better browser support)
313
+ # class MyComponent < ApplicationComponent
314
+ # include StyleCapsule::Component
315
+ # css_scoping_strategy :selector_patching # Default
316
+ #
317
+ # def component_styles
318
+ # <<~CSS
319
+ # .section { color: red; }
320
+ # CSS
321
+ # end
322
+ # end
323
+ # # Output: [data-capsule="abc123"] .section { color: red; }
324
+ def css_scoping_strategy(strategy = nil)
325
+ if strategy.nil?
326
+ # Check if this class has a strategy set
327
+ if defined?(@css_scoping_strategy) && @css_scoping_strategy
328
+ @css_scoping_strategy
329
+ # Otherwise, check parent class (for inheritance)
330
+ elsif superclass.respond_to?(:css_scoping_strategy, true)
331
+ superclass.css_scoping_strategy
332
+ else
333
+ :selector_patching
334
+ end
335
+ else
336
+ unless [:selector_patching, :nesting].include?(strategy)
337
+ raise ArgumentError, "css_scoping_strategy must be :selector_patching or :nesting (got: #{strategy.inspect})"
338
+ end
339
+ @css_scoping_strategy = strategy
340
+ end
341
+ end
342
+
343
+ public :css_scoping_strategy
344
+ end
345
+
346
+ # Module that wraps view_template to add scoped wrapper
347
+ module ViewTemplateWrapper
348
+ def view_template
349
+ if component_styles?
350
+ # Render styles first
351
+ render_capsule_styles
352
+
353
+ # Wrap content in scoped element
354
+ div(data_capsule: component_capsule) do
355
+ super
356
+ end
357
+ else
358
+ # No styles, render normally
359
+ super
360
+ end
361
+ end
362
+ end
363
+
364
+ # Override before_template to render styles (if not already rendered)
365
+ def before_template
366
+ # Styles are rendered in view_template wrapper, but we keep this
367
+ # for components that might call before_template explicitly
368
+ super if defined?(super)
369
+ end
370
+
371
+ # Get the component capsule ID (per-component-type, shared across instances)
372
+ #
373
+ # All instances of the same component class share the same capsule ID.
374
+ # Can be overridden with capsule_id class method for testing.
375
+ #
376
+ # @return [String] The capsule ID (e.g., "a1b2c3d4")
377
+ def component_capsule
378
+ return @component_capsule if defined?(@component_capsule)
379
+
380
+ # Check for custom capsule ID set via class method
381
+ @component_capsule = self.class.custom_capsule_id || generate_capsule_id
382
+ end
383
+
384
+ private
385
+
386
+ # Render the style capsule <style> tag
387
+ #
388
+ # Can render in body (default) or register for head rendering via StylesheetRegistry
389
+ #
390
+ # Supports both instance method (def component_styles) and class method (def self.component_styles).
391
+ # File caching is only allowed for class method component_styles.
392
+ def render_capsule_styles
393
+ css_content = component_styles_content
394
+ return if css_content.nil? || css_content.to_s.strip.empty?
395
+
396
+ scoped_css = scope_css(css_content)
397
+ capsule_id = component_capsule
398
+
399
+ # Check if component uses head rendering
400
+ if head_rendering?
401
+ # Register for head rendering instead of rendering in body
402
+ namespace = self.class.stylesheet_namespace
403
+
404
+ # Get cache configuration from class
405
+ cache_strategy = self.class.inline_cache_strategy || :none
406
+ cache_ttl = self.class.inline_cache_ttl
407
+ cache_proc = self.class.inline_cache_proc
408
+
409
+ # File caching is only allowed for class method component_styles
410
+ if cache_strategy == :file && !file_caching_allowed?
411
+ # Fall back to :none strategy if file caching requested but not allowed
412
+ cache_strategy = :none
413
+ cache_ttl = nil
414
+ cache_proc = nil
415
+ end
416
+
417
+ # Generate cache key based on component class and capsule
418
+ cache_key = (cache_strategy != :none) ? "#{self.class.name}:#{capsule_id}" : nil
419
+
420
+ StylesheetRegistry.register_inline(
421
+ scoped_css,
422
+ namespace: namespace,
423
+ capsule_id: capsule_id,
424
+ cache_key: cache_key,
425
+ cache_strategy: cache_strategy,
426
+ cache_ttl: cache_ttl,
427
+ cache_proc: cache_proc,
428
+ component_class: self.class,
429
+ stylesheet_link_options: self.class.stylesheet_link_options
430
+ )
431
+ else
432
+ # Render <style> tag in body (HTML5 allows this)
433
+ # CSS content is safe (generated from component code, not user input)
434
+ # Phlex's raw() requires the object to be marked as safe
435
+ # Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
436
+ style(type: "text/css") do
437
+ safe_content = respond_to?(:safe) ? safe(scoped_css) : scoped_css.html_safe
438
+ raw(safe_content)
439
+ end
440
+ end
441
+ end
442
+
443
+ # Check if component should use head rendering
444
+ #
445
+ # Checks class-level configuration first, then allows instance override.
446
+ def head_rendering?
447
+ return true if self.class.head_rendering?
448
+ false
449
+ end
450
+
451
+ # Scope CSS and return scoped CSS with attribute selectors
452
+ def scope_css(css_content)
453
+ # Use class-level cache to avoid reprocessing same CSS
454
+ # Include capsule_id and scoping strategy in cache key
455
+ capsule_id = component_capsule
456
+ scoping_strategy = self.class.css_scoping_strategy
457
+ cache_key = "#{self.class.name}:#{capsule_id}:#{scoping_strategy}"
458
+
459
+ if self.class.css_cache.key?(cache_key)
460
+ return self.class.css_cache[cache_key]
461
+ end
462
+
463
+ # Use the configured scoping strategy
464
+ scoped_css = case scoping_strategy
465
+ when :nesting
466
+ CssProcessor.scope_with_nesting(css_content, capsule_id)
467
+ else # :selector_patching (default)
468
+ CssProcessor.scope_selectors(css_content, capsule_id)
469
+ end
470
+
471
+ # Cache at class level (one style block per component type/scope/strategy combination)
472
+ self.class.css_cache[cache_key] = scoped_css
473
+
474
+ scoped_css
475
+ end
476
+
477
+ # Generate a unique scope ID based on component class name (per-component-type)
478
+ #
479
+ # This ensures all instances of the same component class share the same scope ID,
480
+ # similar to how component-based frameworks scope styles per component type.
481
+ #
482
+ # @return [String] The scope ID (e.g., "a1b2c3d4")
483
+ def generate_capsule_id
484
+ class_name = self.class.name || self.class.object_id.to_s
485
+ "a#{Digest::SHA1.hexdigest(class_name)}"[0, 8]
486
+ end
487
+ end
488
+ end