style_capsule 1.0.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +91 -13
- data/lib/style_capsule/class_registry.rb +101 -0
- data/lib/style_capsule/component.rb +4 -1
- data/lib/style_capsule/component_builder.rb +145 -0
- data/lib/style_capsule/css_file_writer.rb +9 -3
- data/lib/style_capsule/helper.rb +9 -5
- data/lib/style_capsule/phlex_helper.rb +8 -4
- data/lib/style_capsule/railtie.rb +9 -17
- data/lib/style_capsule/standalone_helper.rb +196 -0
- data/lib/style_capsule/stylesheet_registry.rb +114 -24
- data/lib/style_capsule/version.rb +1 -1
- data/lib/style_capsule/view_component.rb +4 -1
- data/lib/style_capsule/view_component_helper.rb +8 -4
- data/lib/style_capsule.rb +17 -5
- data/lib/tasks/style_capsule.rake +2 -69
- data/sig/style_capsule.rbs +6 -3
- metadata +10 -9
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module StyleCapsule
|
|
7
|
+
# Standalone helper module for use without Rails
|
|
8
|
+
#
|
|
9
|
+
# This module provides basic HTML generation and CSS scoping functionality
|
|
10
|
+
# without requiring Rails ActionView helpers. It can be included in any
|
|
11
|
+
# framework's view context or used directly.
|
|
12
|
+
#
|
|
13
|
+
# @example Usage in Sinatra
|
|
14
|
+
# class MyApp < Sinatra::Base
|
|
15
|
+
# helpers StyleCapsule::StandaloneHelper
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Usage in plain Ruby
|
|
19
|
+
# class MyView
|
|
20
|
+
# include StyleCapsule::StandaloneHelper
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Usage in ERB (non-Rails)
|
|
24
|
+
# # In your ERB template context
|
|
25
|
+
# include StyleCapsule::StandaloneHelper
|
|
26
|
+
# style_capsule do
|
|
27
|
+
# "<style>.section { color: red; }</style><div class='section'>Content</div>"
|
|
28
|
+
# end
|
|
29
|
+
module StandaloneHelper
|
|
30
|
+
# Maximum HTML content size (10MB) to prevent DoS attacks
|
|
31
|
+
MAX_HTML_SIZE = 10_000_000
|
|
32
|
+
|
|
33
|
+
# Generate capsule ID based on caller location for uniqueness
|
|
34
|
+
def generate_capsule_id(css_content)
|
|
35
|
+
# Use caller location + CSS content for uniqueness
|
|
36
|
+
caller_info = caller_locations(1, 1).first
|
|
37
|
+
capsule_key = "#{caller_info.path}:#{caller_info.lineno}:#{css_content}"
|
|
38
|
+
"a#{Digest::SHA1.hexdigest(capsule_key)}"[0, 8]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Scope CSS content and return scoped CSS
|
|
42
|
+
def scope_css(css_content, capsule_id)
|
|
43
|
+
# Use thread-local cache to avoid reprocessing
|
|
44
|
+
cache_key = "style_capsule_#{capsule_id}"
|
|
45
|
+
|
|
46
|
+
if Thread.current[cache_key]
|
|
47
|
+
return Thread.current[cache_key]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
51
|
+
Thread.current[cache_key] = scoped_css
|
|
52
|
+
scoped_css
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate HTML tag without Rails helpers
|
|
56
|
+
#
|
|
57
|
+
# @param tag [String, Symbol] HTML tag name
|
|
58
|
+
# @param content [String, nil] Tag content (or use block)
|
|
59
|
+
# @param options [Hash] HTML attributes
|
|
60
|
+
# @param block [Proc] Block for tag content
|
|
61
|
+
# @return [String] HTML string
|
|
62
|
+
def content_tag(tag, content = nil, **options, &block)
|
|
63
|
+
tag_name = tag.to_s
|
|
64
|
+
content = capture(&block) if block_given? && content.nil?
|
|
65
|
+
content ||= ""
|
|
66
|
+
|
|
67
|
+
attrs = options.map do |k, v|
|
|
68
|
+
if v.is_a?(Hash)
|
|
69
|
+
# Handle nested attributes like data: { capsule: "abc" }
|
|
70
|
+
v.map { |nk, nv| %(#{k}-#{nk}="#{escape_html_attr(nv)}") }.join(" ")
|
|
71
|
+
else
|
|
72
|
+
%(#{k}="#{escape_html_attr(v)}")
|
|
73
|
+
end
|
|
74
|
+
end.join(" ")
|
|
75
|
+
|
|
76
|
+
attrs = " #{attrs}" unless attrs.empty?
|
|
77
|
+
"<#{tag_name}#{attrs}>#{content}</#{tag_name}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Capture block content (simplified version without Rails)
|
|
81
|
+
#
|
|
82
|
+
# @param block [Proc] Block to capture
|
|
83
|
+
# @return [String] Captured content
|
|
84
|
+
def capture(&block)
|
|
85
|
+
return "" unless block_given?
|
|
86
|
+
block.call.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Mark string as HTML-safe (for compatibility)
|
|
90
|
+
#
|
|
91
|
+
# @param string [String] String to mark as safe
|
|
92
|
+
# @return [String] HTML-safe string
|
|
93
|
+
def html_safe(string)
|
|
94
|
+
# In non-Rails context, just return the string
|
|
95
|
+
# If ActiveSupport is available, use its html_safe
|
|
96
|
+
if string.respond_to?(:html_safe)
|
|
97
|
+
string.html_safe
|
|
98
|
+
else
|
|
99
|
+
string
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Raw string (no HTML escaping)
|
|
104
|
+
#
|
|
105
|
+
# @param string [String] String to return as-is
|
|
106
|
+
# @return [String] Raw string
|
|
107
|
+
def raw(string)
|
|
108
|
+
html_safe(string.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ERB helper: automatically wraps content in scoped div and processes CSS
|
|
112
|
+
#
|
|
113
|
+
# @param css_content [String, nil] CSS content (or extract from block)
|
|
114
|
+
# @param capsule_id [String, nil] Optional capsule ID
|
|
115
|
+
# @param content_block [Proc] Block containing HTML content
|
|
116
|
+
# @return [String] HTML with scoped CSS and wrapped content
|
|
117
|
+
def style_capsule(css_content = nil, capsule_id: nil, &content_block)
|
|
118
|
+
html_content = nil
|
|
119
|
+
|
|
120
|
+
# If CSS content is provided as argument, use it
|
|
121
|
+
# Otherwise, extract from content block
|
|
122
|
+
if css_content.nil? && block_given?
|
|
123
|
+
full_content = capture(&content_block)
|
|
124
|
+
|
|
125
|
+
# Validate HTML content size to prevent DoS attacks
|
|
126
|
+
if full_content.bytesize > MAX_HTML_SIZE
|
|
127
|
+
raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Extract <style> tags from content
|
|
131
|
+
style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
|
|
132
|
+
if style_match
|
|
133
|
+
css_content = style_match[1]
|
|
134
|
+
html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
|
|
135
|
+
else
|
|
136
|
+
css_content = nil
|
|
137
|
+
html_content = full_content
|
|
138
|
+
end
|
|
139
|
+
elsif css_content && block_given?
|
|
140
|
+
html_content = capture(&content_block)
|
|
141
|
+
elsif css_content && !block_given?
|
|
142
|
+
# CSS provided but no content block - just return scoped CSS
|
|
143
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
144
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
145
|
+
return content_tag(:style, raw(scoped_css), type: "text/css")
|
|
146
|
+
else
|
|
147
|
+
return ""
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# If no CSS, just return content
|
|
151
|
+
return html_safe(html_content) if css_content.nil? || css_content.to_s.strip.empty?
|
|
152
|
+
|
|
153
|
+
# Use provided capsule_id or generate one
|
|
154
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
155
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
156
|
+
|
|
157
|
+
# Render style tag and wrapped content
|
|
158
|
+
style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
|
|
159
|
+
wrapped_content = content_tag(:div, raw(html_content), data: {capsule: capsule_id})
|
|
160
|
+
|
|
161
|
+
html_safe(style_tag + wrapped_content)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Register a stylesheet file for head rendering
|
|
165
|
+
#
|
|
166
|
+
# @param file_path [String] Path to stylesheet
|
|
167
|
+
# @param namespace [Symbol, String, nil] Optional namespace
|
|
168
|
+
# @param options [Hash] Options for stylesheet link tag
|
|
169
|
+
# @return [void]
|
|
170
|
+
def register_stylesheet(file_path, namespace: nil, **options)
|
|
171
|
+
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Render StyleCapsule registered stylesheets
|
|
175
|
+
#
|
|
176
|
+
# @param namespace [Symbol, String, nil] Optional namespace to render
|
|
177
|
+
# @return [String] HTML-safe string with stylesheet tags
|
|
178
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
179
|
+
StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
183
|
+
# This method name will be removed in a future version.
|
|
184
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Escape HTML attribute value
|
|
189
|
+
#
|
|
190
|
+
# @param value [String] Value to escape
|
|
191
|
+
# @return [String] Escaped value
|
|
192
|
+
def escape_html_attr(value)
|
|
193
|
+
CGI.escapeHTML(value.to_s)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/current_attributes"
|
|
4
|
-
|
|
5
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
|
+
|
|
6
14
|
# Hybrid registry for stylesheet files that need to be injected into <head>
|
|
7
15
|
#
|
|
8
16
|
# Uses a process-wide manifest for static file paths (like Propshaft) and request-scoped
|
|
@@ -52,7 +60,7 @@ module StyleCapsule
|
|
|
52
60
|
# body(&block)
|
|
53
61
|
# end
|
|
54
62
|
# end
|
|
55
|
-
class StylesheetRegistry <
|
|
63
|
+
class StylesheetRegistry < StyleCapsule.stylesheet_registry_parent_class
|
|
56
64
|
# Default namespace for backward compatibility
|
|
57
65
|
DEFAULT_NAMESPACE = :default
|
|
58
66
|
|
|
@@ -68,10 +76,74 @@ module StyleCapsule
|
|
|
68
76
|
@last_cleanup_time = nil # rubocop:disable Style/ClassVars
|
|
69
77
|
|
|
70
78
|
# Request-scoped storage for inline CSS only
|
|
71
|
-
attribute
|
|
79
|
+
# Only define attribute if we're inheriting from CurrentAttributes
|
|
80
|
+
if defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
|
|
81
|
+
attribute :inline_stylesheets
|
|
82
|
+
end
|
|
72
83
|
|
|
73
84
|
class << self
|
|
74
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
|
|
75
147
|
end
|
|
76
148
|
|
|
77
149
|
# Normalize namespace (nil/blank becomes DEFAULT_NAMESPACE)
|
|
@@ -177,14 +249,14 @@ module StyleCapsule
|
|
|
177
249
|
final_css = cached_css || css_content
|
|
178
250
|
|
|
179
251
|
# Store in request-scoped registry
|
|
180
|
-
registry =
|
|
252
|
+
registry = inline_stylesheets
|
|
181
253
|
registry[ns] ||= []
|
|
182
254
|
registry[ns] << {
|
|
183
255
|
type: :inline,
|
|
184
256
|
css_content: final_css,
|
|
185
257
|
capsule_id: capsule_id
|
|
186
258
|
}
|
|
187
|
-
|
|
259
|
+
self.inline_stylesheets = registry
|
|
188
260
|
|
|
189
261
|
# Cache the CSS if strategy is enabled and not already cached
|
|
190
262
|
if cache_strategy != :none && cache_key && !cached_css && cache_strategy != :file
|
|
@@ -216,7 +288,7 @@ module StyleCapsule
|
|
|
216
288
|
# Check expiration based on strategy
|
|
217
289
|
case cache_strategy
|
|
218
290
|
when :time
|
|
219
|
-
return nil if cache_ttl && cached_entry[:expires_at] &&
|
|
291
|
+
return nil if cache_ttl && cached_entry[:expires_at] && current_time > cached_entry[:expires_at]
|
|
220
292
|
when :proc
|
|
221
293
|
return nil unless cache_proc
|
|
222
294
|
# Proc should validate cache entry
|
|
@@ -242,7 +314,13 @@ module StyleCapsule
|
|
|
242
314
|
|
|
243
315
|
case cache_strategy
|
|
244
316
|
when :time
|
|
245
|
-
|
|
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
|
|
246
324
|
when :proc
|
|
247
325
|
if cache_proc
|
|
248
326
|
_key, _should_cache, proc_expires = cache_proc.call(css_content, capsule_id, namespace)
|
|
@@ -252,7 +330,7 @@ module StyleCapsule
|
|
|
252
330
|
|
|
253
331
|
@inline_cache[cache_key] = {
|
|
254
332
|
css_content: css_content,
|
|
255
|
-
cached_at:
|
|
333
|
+
cached_at: current_time,
|
|
256
334
|
expires_at: expires_at
|
|
257
335
|
}
|
|
258
336
|
end
|
|
@@ -287,18 +365,18 @@ module StyleCapsule
|
|
|
287
365
|
def self.cleanup_expired_cache
|
|
288
366
|
return 0 if @inline_cache.empty?
|
|
289
367
|
|
|
290
|
-
|
|
368
|
+
now = current_time
|
|
291
369
|
expired_keys = []
|
|
292
370
|
|
|
293
371
|
@inline_cache.each do |cache_key, entry|
|
|
294
372
|
# Remove entries that have an expires_at time and it's in the past
|
|
295
|
-
if entry[:expires_at] &&
|
|
373
|
+
if entry[:expires_at] && now > entry[:expires_at]
|
|
296
374
|
expired_keys << cache_key
|
|
297
375
|
end
|
|
298
376
|
end
|
|
299
377
|
|
|
300
378
|
expired_keys.each { |key| @inline_cache.delete(key) }
|
|
301
|
-
@last_cleanup_time =
|
|
379
|
+
@last_cleanup_time = now
|
|
302
380
|
|
|
303
381
|
expired_keys.size
|
|
304
382
|
end
|
|
@@ -314,7 +392,7 @@ module StyleCapsule
|
|
|
314
392
|
# Cleanup every 5 minutes (300 seconds) to balance memory usage and performance
|
|
315
393
|
cleanup_interval = 300
|
|
316
394
|
|
|
317
|
-
if @last_cleanup_time.nil? || (
|
|
395
|
+
if @last_cleanup_time.nil? || (current_time - @last_cleanup_time) > cleanup_interval
|
|
318
396
|
cleanup_expired_cache
|
|
319
397
|
end
|
|
320
398
|
end
|
|
@@ -332,7 +410,7 @@ module StyleCapsule
|
|
|
332
410
|
#
|
|
333
411
|
# @return [Hash<Symbol, Array<Hash>>] Hash of namespace => array of inline stylesheet registrations
|
|
334
412
|
def self.request_inline_stylesheets
|
|
335
|
-
|
|
413
|
+
inline_stylesheets
|
|
336
414
|
end
|
|
337
415
|
|
|
338
416
|
# Get all stylesheets (files + inline) for a specific namespace
|
|
@@ -361,12 +439,12 @@ module StyleCapsule
|
|
|
361
439
|
# @return [void]
|
|
362
440
|
def self.clear(namespace: nil)
|
|
363
441
|
if namespace.nil?
|
|
364
|
-
|
|
442
|
+
self.inline_stylesheets = {}
|
|
365
443
|
else
|
|
366
444
|
ns = normalize_namespace(namespace)
|
|
367
|
-
registry =
|
|
445
|
+
registry = inline_stylesheets
|
|
368
446
|
registry.delete(ns)
|
|
369
|
-
|
|
447
|
+
self.inline_stylesheets = registry
|
|
370
448
|
end
|
|
371
449
|
end
|
|
372
450
|
|
|
@@ -411,7 +489,7 @@ module StyleCapsule
|
|
|
411
489
|
end
|
|
412
490
|
|
|
413
491
|
clear # Clear request-scoped inline CSS only
|
|
414
|
-
return ""
|
|
492
|
+
return safe_string("") if all_stylesheets.empty?
|
|
415
493
|
|
|
416
494
|
all_stylesheets.map do |stylesheet|
|
|
417
495
|
if stylesheet[:type] == :inline
|
|
@@ -419,7 +497,7 @@ module StyleCapsule
|
|
|
419
497
|
else
|
|
420
498
|
render_file_stylesheet(stylesheet, view_context)
|
|
421
499
|
end
|
|
422
|
-
end.join("\n").
|
|
500
|
+
end.join("\n").then { |s| safe_string(s) }
|
|
423
501
|
|
|
424
502
|
else
|
|
425
503
|
# Render specific namespace
|
|
@@ -427,7 +505,7 @@ module StyleCapsule
|
|
|
427
505
|
stylesheets = stylesheets_for(namespace: ns).dup
|
|
428
506
|
clear(namespace: ns) # Clear request-scoped inline CSS only
|
|
429
507
|
|
|
430
|
-
return ""
|
|
508
|
+
return safe_string("") if stylesheets.empty?
|
|
431
509
|
|
|
432
510
|
stylesheets.map do |stylesheet|
|
|
433
511
|
if stylesheet[:type] == :inline
|
|
@@ -435,7 +513,7 @@ module StyleCapsule
|
|
|
435
513
|
else
|
|
436
514
|
render_file_stylesheet(stylesheet, view_context)
|
|
437
515
|
end
|
|
438
|
-
end.join("\n").
|
|
516
|
+
end.join("\n").then { |s| safe_string(s) }
|
|
439
517
|
|
|
440
518
|
end
|
|
441
519
|
end
|
|
@@ -473,7 +551,7 @@ module StyleCapsule
|
|
|
473
551
|
# Fallback if no view context
|
|
474
552
|
href = "/assets/#{file_path}.css"
|
|
475
553
|
tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
|
|
476
|
-
%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>)
|
|
554
|
+
safe_string(%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>))
|
|
477
555
|
end
|
|
478
556
|
end
|
|
479
557
|
|
|
@@ -486,9 +564,21 @@ module StyleCapsule
|
|
|
486
564
|
# Construct HTML manually to avoid any HTML escaping issues
|
|
487
565
|
# CSS content should not be HTML-escaped as it's inside a <style> tag
|
|
488
566
|
# Using string interpolation with html_safe ensures CSS is not escaped
|
|
489
|
-
%(<style type="text/css">#{css_content}</style>)
|
|
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
|
|
490
580
|
end
|
|
491
581
|
|
|
492
|
-
private_class_method :render_file_stylesheet, :render_inline_stylesheet
|
|
582
|
+
private_class_method :render_file_stylesheet, :render_inline_stylesheet, :safe_string
|
|
493
583
|
end
|
|
494
584
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
-
|
|
4
|
+
# ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
|
|
5
5
|
|
|
6
6
|
module StyleCapsule
|
|
7
7
|
# ViewComponent component concern for encapsulated CSS
|
|
@@ -89,6 +89,9 @@ module StyleCapsule
|
|
|
89
89
|
|
|
90
90
|
# Use prepend to wrap call method
|
|
91
91
|
base.prepend(CallWrapper)
|
|
92
|
+
|
|
93
|
+
# Register class for Rails-friendly tracking
|
|
94
|
+
ClassRegistry.register(base)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
module ClassMethods
|
|
@@ -36,18 +36,22 @@ module StyleCapsule
|
|
|
36
36
|
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# Render StyleCapsule registered stylesheets
|
|
39
|
+
# Render StyleCapsule registered stylesheets
|
|
40
40
|
#
|
|
41
41
|
# Usage in ViewComponent layouts:
|
|
42
42
|
# def call
|
|
43
|
-
# helpers.
|
|
44
|
-
# helpers.
|
|
43
|
+
# helpers.stylesheet_registry_tags
|
|
44
|
+
# helpers.stylesheet_registry_tags(namespace: :admin)
|
|
45
45
|
# end
|
|
46
46
|
#
|
|
47
47
|
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
48
48
|
# @return [String] HTML-safe string with stylesheet tags
|
|
49
|
-
def
|
|
49
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
50
50
|
StyleCapsule::StylesheetRegistry.render_head_stylesheets(helpers, namespace: namespace)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
54
|
+
# This method name will be removed in a future version.
|
|
55
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
52
56
|
end
|
|
53
57
|
end
|
data/lib/style_capsule.rb
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
-
require
|
|
4
|
+
# Conditionally require ActiveSupport string extensions if available
|
|
5
|
+
# For non-Rails usage, these are optional
|
|
6
|
+
# Check first to avoid exception handling overhead in common case (Rails apps)
|
|
7
|
+
unless defined?(ActiveSupport) || String.method_defined?(:html_safe)
|
|
8
|
+
begin
|
|
9
|
+
require "active_support/core_ext/string"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
# ActiveSupport not available - core functionality still works
|
|
12
|
+
end
|
|
13
|
+
end
|
|
5
14
|
|
|
6
15
|
# StyleCapsule provides attribute-based CSS scoping for component encapsulation
|
|
7
16
|
# in Phlex components, ViewComponent components, and ERB templates.
|
|
@@ -55,8 +64,8 @@ require "active_support/core_ext/string"
|
|
|
55
64
|
# <div class="section">Content</div>
|
|
56
65
|
# <% end %>
|
|
57
66
|
#
|
|
58
|
-
# <%=
|
|
59
|
-
# <%=
|
|
67
|
+
# <%= stylesheet_registry_tags %>
|
|
68
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
60
69
|
#
|
|
61
70
|
# @example Namespace Support
|
|
62
71
|
# # Register stylesheets with namespaces
|
|
@@ -64,10 +73,10 @@ require "active_support/core_ext/string"
|
|
|
64
73
|
# StyleCapsule::StylesheetRegistry.register('stylesheets/user/profile', namespace: :user)
|
|
65
74
|
#
|
|
66
75
|
# # Render all namespaces (default)
|
|
67
|
-
# <%=
|
|
76
|
+
# <%= stylesheet_registry_tags %>
|
|
68
77
|
#
|
|
69
78
|
# # Render specific namespace
|
|
70
|
-
# <%=
|
|
79
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
71
80
|
#
|
|
72
81
|
# @example File-Based Caching (HTTP Caching)
|
|
73
82
|
# class MyComponent < ApplicationComponent
|
|
@@ -83,11 +92,14 @@ module StyleCapsule
|
|
|
83
92
|
require_relative "style_capsule/css_processor"
|
|
84
93
|
require_relative "style_capsule/css_file_writer"
|
|
85
94
|
require_relative "style_capsule/stylesheet_registry"
|
|
95
|
+
require_relative "style_capsule/class_registry"
|
|
86
96
|
require_relative "style_capsule/component_styles_support"
|
|
87
97
|
require_relative "style_capsule/component"
|
|
98
|
+
require_relative "style_capsule/standalone_helper"
|
|
88
99
|
require_relative "style_capsule/helper"
|
|
89
100
|
require_relative "style_capsule/phlex_helper"
|
|
90
101
|
require_relative "style_capsule/view_component"
|
|
91
102
|
require_relative "style_capsule/view_component_helper"
|
|
103
|
+
require_relative "style_capsule/component_builder"
|
|
92
104
|
require_relative "style_capsule/railtie" if defined?(Rails) && defined?(Rails::Railtie)
|
|
93
105
|
end
|
|
@@ -3,76 +3,9 @@
|
|
|
3
3
|
namespace :style_capsule do
|
|
4
4
|
desc "Build StyleCapsule CSS files from components (similar to Tailwind CSS build)"
|
|
5
5
|
task build: :environment do
|
|
6
|
-
require "style_capsule/
|
|
7
|
-
|
|
8
|
-
# Ensure output directory exists
|
|
9
|
-
StyleCapsule::CssFileWriter.ensure_output_directory
|
|
10
|
-
|
|
11
|
-
# Collect all component classes that use StyleCapsule
|
|
12
|
-
component_classes = []
|
|
13
|
-
|
|
14
|
-
# Find Phlex components
|
|
15
|
-
if defined?(Phlex::HTML)
|
|
16
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
17
|
-
if klass < Phlex::HTML && klass.included_modules.include?(StyleCapsule::Component)
|
|
18
|
-
component_classes << klass
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Find ViewComponent components
|
|
24
|
-
# Wrap in begin/rescue to handle ViewComponent loading errors (e.g., version compatibility issues)
|
|
25
|
-
begin
|
|
26
|
-
if defined?(ViewComponent::Base)
|
|
27
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
28
|
-
if klass < ViewComponent::Base && klass.included_modules.include?(StyleCapsule::ViewComponent)
|
|
29
|
-
component_classes << klass
|
|
30
|
-
end
|
|
31
|
-
rescue
|
|
32
|
-
# Skip this class if checking inheritance triggers ViewComponent loading errors
|
|
33
|
-
# (e.g., ViewComponent 2.83.0 has a bug with Gem::Version#to_f)
|
|
34
|
-
next
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
rescue
|
|
38
|
-
# ViewComponent may have loading issues (e.g., version compatibility)
|
|
39
|
-
# Silently skip ViewComponent components if there's an error
|
|
40
|
-
# This allows the rake task to continue with Phlex components
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Generate CSS files for each component
|
|
44
|
-
component_classes.each do |component_class|
|
|
45
|
-
next unless component_class.inline_cache_strategy == :file
|
|
46
|
-
# Check for class method component_styles (required for file caching)
|
|
47
|
-
next unless component_class.respond_to?(:component_styles, false)
|
|
48
|
-
|
|
49
|
-
begin
|
|
50
|
-
# Use class method component_styles for file caching
|
|
51
|
-
css_content = component_class.component_styles
|
|
52
|
-
next if css_content.nil? || css_content.to_s.strip.empty?
|
|
53
|
-
|
|
54
|
-
# Create a temporary instance to get capsule
|
|
55
|
-
# Some components might require arguments, so we catch errors
|
|
56
|
-
instance = component_class.new
|
|
57
|
-
capsule_id = instance.component_capsule
|
|
58
|
-
scoped_css = instance.send(:scope_css, css_content)
|
|
59
|
-
|
|
60
|
-
# Write CSS file
|
|
61
|
-
file_path = StyleCapsule::CssFileWriter.write_css(
|
|
62
|
-
css_content: scoped_css,
|
|
63
|
-
component_class: component_class,
|
|
64
|
-
capsule_id: capsule_id
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
puts "Generated: #{file_path}" if file_path
|
|
68
|
-
rescue ArgumentError, NoMethodError => e
|
|
69
|
-
# Component requires arguments or has dependencies - skip it
|
|
70
|
-
puts "Skipped #{component_class.name}: #{e.message}"
|
|
71
|
-
next
|
|
72
|
-
end
|
|
73
|
-
end
|
|
6
|
+
require "style_capsule/component_builder"
|
|
74
7
|
|
|
75
|
-
|
|
8
|
+
StyleCapsule::ComponentBuilder.build_all(output_proc: ->(msg) { puts msg })
|
|
76
9
|
end
|
|
77
10
|
|
|
78
11
|
desc "Clear StyleCapsule generated CSS files"
|
data/sig/style_capsule.rbs
CHANGED
|
@@ -88,7 +88,8 @@ module StyleCapsule
|
|
|
88
88
|
|
|
89
89
|
module ViewComponentHelper
|
|
90
90
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
91
|
-
def
|
|
91
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> String
|
|
92
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> String # deprecated
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
module Helper
|
|
@@ -96,12 +97,14 @@ module StyleCapsule
|
|
|
96
97
|
def scope_css: (String css_content, String capsule_id) -> String
|
|
97
98
|
def style_capsule: (?String css_content, ?capsule_id: String | nil) { () -> String } -> String
|
|
98
99
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
99
|
-
def
|
|
100
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> String
|
|
101
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> String # deprecated
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
module PhlexHelper
|
|
103
105
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
104
|
-
def
|
|
106
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> void
|
|
107
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> void # deprecated
|
|
105
108
|
end
|
|
106
109
|
|
|
107
110
|
class Railtie < Rails::Railtie
|