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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.md +22 -0
- data/README.md +398 -0
- data/SECURITY.md +55 -0
- data/lib/style_capsule/component.rb +485 -0
- data/lib/style_capsule/component_styles_support.rb +73 -0
- data/lib/style_capsule/css_file_writer.rb +244 -0
- data/lib/style_capsule/css_processor.rb +182 -0
- data/lib/style_capsule/helper.rb +163 -0
- data/lib/style_capsule/phlex_helper.rb +66 -0
- data/lib/style_capsule/railtie.rb +68 -0
- data/lib/style_capsule/stylesheet_registry.rb +494 -0
- data/lib/style_capsule/version.rb +5 -0
- data/lib/style_capsule/view_component.rb +479 -0
- data/lib/style_capsule/view_component_helper.rb +53 -0
- data/lib/style_capsule.rb +93 -0
- data/lib/tasks/style_capsule.rake +89 -0
- data/sig/style_capsule.rbs +110 -0
- metadata +305 -0
|
@@ -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
|