style_capsule 1.0.2

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