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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE.md +22 -0
- data/README.md +476 -0
- data/SECURITY.md +55 -0
- data/lib/style_capsule/class_registry.rb +101 -0
- data/lib/style_capsule/component.rb +488 -0
- data/lib/style_capsule/component_builder.rb +145 -0
- data/lib/style_capsule/component_styles_support.rb +73 -0
- data/lib/style_capsule/css_file_writer.rb +250 -0
- data/lib/style_capsule/css_processor.rb +182 -0
- data/lib/style_capsule/helper.rb +167 -0
- data/lib/style_capsule/phlex_helper.rb +70 -0
- data/lib/style_capsule/railtie.rb +60 -0
- data/lib/style_capsule/standalone_helper.rb +196 -0
- data/lib/style_capsule/stylesheet_registry.rb +584 -0
- data/lib/style_capsule/version.rb +5 -0
- data/lib/style_capsule/view_component.rb +482 -0
- data/lib/style_capsule/view_component_helper.rb +57 -0
- data/lib/style_capsule.rb +105 -0
- data/lib/tasks/style_capsule.rake +22 -0
- data/sig/style_capsule.rbs +113 -0
- metadata +306 -0
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
# ERB Helper module for use in Rails views
|
|
8
|
+
#
|
|
9
|
+
# This module is automatically included in ActionView::Base via Railtie.
|
|
10
|
+
# No manual inclusion is required - helpers are available in all ERB templates.
|
|
11
|
+
#
|
|
12
|
+
# Usage in ERB templates:
|
|
13
|
+
#
|
|
14
|
+
# <%= style_capsule do %>
|
|
15
|
+
# <style>
|
|
16
|
+
# .section { color: red; }
|
|
17
|
+
# .heading:hover { opacity: 0.8; }
|
|
18
|
+
# </style>
|
|
19
|
+
# <div class="section">
|
|
20
|
+
# <h2 class="heading">Hello</h2>
|
|
21
|
+
# </div>
|
|
22
|
+
# <% end %>
|
|
23
|
+
#
|
|
24
|
+
# The CSS will be automatically scoped and content wrapped in a scoped div.
|
|
25
|
+
# The <style> tag will be extracted and processed separately.
|
|
26
|
+
module Helper
|
|
27
|
+
# Maximum HTML content size (10MB) to prevent DoS attacks
|
|
28
|
+
MAX_HTML_SIZE = 10_000_000
|
|
29
|
+
# Generate capsule ID based on caller location for uniqueness
|
|
30
|
+
def generate_capsule_id(css_content)
|
|
31
|
+
# Use caller location + CSS content for uniqueness
|
|
32
|
+
caller_info = caller_locations(1, 1).first
|
|
33
|
+
capsule_key = "#{caller_info.path}:#{caller_info.lineno}:#{css_content}"
|
|
34
|
+
"a#{Digest::SHA1.hexdigest(capsule_key)}"[0, 8]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Scope CSS content and return scoped CSS
|
|
38
|
+
def scope_css(css_content, capsule_id)
|
|
39
|
+
# Use thread-local cache to avoid reprocessing
|
|
40
|
+
cache_key = "style_capsule_#{capsule_id}"
|
|
41
|
+
|
|
42
|
+
if Thread.current[cache_key]
|
|
43
|
+
return Thread.current[cache_key]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
47
|
+
Thread.current[cache_key] = scoped_css
|
|
48
|
+
scoped_css
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ERB helper: automatically wraps content in scoped div and processes CSS
|
|
52
|
+
#
|
|
53
|
+
# Extracts <style> tags from content, processes them, and wraps everything
|
|
54
|
+
# in a scoped div. CSS can be in <style> tags or as a separate block.
|
|
55
|
+
#
|
|
56
|
+
# Usage:
|
|
57
|
+
# <%= style_capsule do %>
|
|
58
|
+
# <style>
|
|
59
|
+
# .section { color: red; }
|
|
60
|
+
# .heading:hover { opacity: 0.8; }
|
|
61
|
+
# </style>
|
|
62
|
+
# <div class="section">
|
|
63
|
+
# <h2 class="heading">Hello</h2>
|
|
64
|
+
# </div>
|
|
65
|
+
# <% end %>
|
|
66
|
+
#
|
|
67
|
+
# Or with CSS as separate content:
|
|
68
|
+
# <% css_content = capture do %>
|
|
69
|
+
# .section { color: red; }
|
|
70
|
+
# <% end %>
|
|
71
|
+
# <%= style_capsule(css_content) do %>
|
|
72
|
+
# <div class="section">Content</div>
|
|
73
|
+
# <% end %>
|
|
74
|
+
#
|
|
75
|
+
# Or with manual capsule ID (for testing/exact naming):
|
|
76
|
+
# <%= style_capsule(capsule_id: "test-123") do %>
|
|
77
|
+
# <style>.section { color: red; }</style>
|
|
78
|
+
# <div class="section">Content</div>
|
|
79
|
+
# <% end %>
|
|
80
|
+
#
|
|
81
|
+
# Or with both CSS content and manual capsule ID:
|
|
82
|
+
# <% css_content = ".section { color: red; }" %>
|
|
83
|
+
# <%= style_capsule(css_content, capsule_id: "test-123") do %>
|
|
84
|
+
# <div class="section">Content</div>
|
|
85
|
+
# <% end %>
|
|
86
|
+
def style_capsule(css_content = nil, capsule_id: nil, &content_block)
|
|
87
|
+
html_content = nil
|
|
88
|
+
|
|
89
|
+
# If CSS content is provided as argument, use it
|
|
90
|
+
# Otherwise, extract from content block
|
|
91
|
+
if css_content.nil? && block_given?
|
|
92
|
+
full_content = capture(&content_block)
|
|
93
|
+
|
|
94
|
+
# Validate HTML content size to prevent DoS attacks
|
|
95
|
+
if full_content.bytesize > MAX_HTML_SIZE
|
|
96
|
+
raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Extract <style> tags from content
|
|
100
|
+
# Note: Pattern uses non-greedy matching (.*?) to minimize backtracking
|
|
101
|
+
# Size limit (MAX_HTML_SIZE) mitigates ReDoS risk from malicious input
|
|
102
|
+
style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
|
|
103
|
+
if style_match
|
|
104
|
+
css_content = style_match[1]
|
|
105
|
+
# Use sub instead of gsub to only remove first occurrence (reduces backtracking)
|
|
106
|
+
html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
|
|
107
|
+
else
|
|
108
|
+
# No style tag found, treat entire content as HTML
|
|
109
|
+
css_content = nil
|
|
110
|
+
html_content = full_content
|
|
111
|
+
end
|
|
112
|
+
elsif css_content && block_given?
|
|
113
|
+
html_content = capture(&content_block)
|
|
114
|
+
elsif css_content && !block_given?
|
|
115
|
+
# CSS provided but no content block - just return scoped CSS
|
|
116
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
117
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
118
|
+
return content_tag(:style, raw(scoped_css), type: "text/css").html_safe
|
|
119
|
+
else
|
|
120
|
+
return ""
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# If no CSS, just return content
|
|
124
|
+
return html_content.html_safe if css_content.nil? || css_content.to_s.strip.empty?
|
|
125
|
+
|
|
126
|
+
# Use provided capsule_id or generate one
|
|
127
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
128
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
129
|
+
|
|
130
|
+
# Render style tag and wrapped content
|
|
131
|
+
style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
|
|
132
|
+
wrapped_content = content_tag(:div, raw(html_content), data: {capsule: capsule_id})
|
|
133
|
+
|
|
134
|
+
(style_tag + wrapped_content).html_safe
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Register a stylesheet file for head rendering
|
|
138
|
+
#
|
|
139
|
+
# Usage in ERB:
|
|
140
|
+
# <% register_stylesheet("stylesheets/user/my_component", "data-turbo-track": "reload") %>
|
|
141
|
+
# <% register_stylesheet("stylesheets/admin/dashboard", namespace: :admin) %>
|
|
142
|
+
#
|
|
143
|
+
# @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
|
|
144
|
+
# @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
|
|
145
|
+
# @param options [Hash] Options for stylesheet_link_tag
|
|
146
|
+
# @return [void]
|
|
147
|
+
def register_stylesheet(file_path, namespace: nil, **options)
|
|
148
|
+
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Render StyleCapsule registered stylesheets
|
|
152
|
+
#
|
|
153
|
+
# Usage in ERB:
|
|
154
|
+
# <%= stylesheet_registry_tags %>
|
|
155
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
156
|
+
#
|
|
157
|
+
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
158
|
+
# @return [String] HTML-safe string with stylesheet tags
|
|
159
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
160
|
+
StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
164
|
+
# This method name will be removed in a future version.
|
|
165
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Phlex helper module for StyleCapsule stylesheet registry
|
|
5
|
+
#
|
|
6
|
+
# Include this in your base Phlex component class (e.g., ApplicationComponent):
|
|
7
|
+
# class ApplicationComponent < Phlex::HTML
|
|
8
|
+
# include StyleCapsule::PhlexHelper
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Usage in Phlex layouts:
|
|
12
|
+
# head do
|
|
13
|
+
# stylesheet_registrymap_tags
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Usage in Phlex components:
|
|
17
|
+
# def view_template
|
|
18
|
+
# register_stylesheet("stylesheets/user/my_component")
|
|
19
|
+
# div { "Content" }
|
|
20
|
+
# end
|
|
21
|
+
module PhlexHelper
|
|
22
|
+
# Register a stylesheet file for head rendering
|
|
23
|
+
#
|
|
24
|
+
# Usage in Phlex components:
|
|
25
|
+
# def view_template
|
|
26
|
+
# register_stylesheet("stylesheets/user/my_component", "data-turbo-track": "reload")
|
|
27
|
+
# register_stylesheet("stylesheets/admin/dashboard", namespace: :admin)
|
|
28
|
+
# div { "Content" }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# @param file_path [String] Path to stylesheet (relative to app/assets/stylesheets)
|
|
32
|
+
# @param namespace [Symbol, String, nil] Optional namespace for separation (nil/blank uses default)
|
|
33
|
+
# @param options [Hash] Options for stylesheet_link_tag
|
|
34
|
+
# @return [void]
|
|
35
|
+
def register_stylesheet(file_path, namespace: nil, **options)
|
|
36
|
+
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Render StyleCapsule registered stylesheets
|
|
40
|
+
#
|
|
41
|
+
# Usage in Phlex layouts:
|
|
42
|
+
# head do
|
|
43
|
+
# stylesheet_registry_tags
|
|
44
|
+
# stylesheet_registry_tags(namespace: :admin)
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
48
|
+
# @return [void] Renders stylesheet tags via raw
|
|
49
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
50
|
+
output = StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context, namespace: namespace)
|
|
51
|
+
# Phlex's raw() requires the object to be marked as safe
|
|
52
|
+
# Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
|
|
53
|
+
# The output from render_head_stylesheets is already html_safe (SafeBuffer)
|
|
54
|
+
output_string = output.to_s
|
|
55
|
+
|
|
56
|
+
if respond_to?(:safe)
|
|
57
|
+
# Real Phlex component - use raw() for rendering
|
|
58
|
+
safe_content = safe(output_string)
|
|
59
|
+
raw(safe_content)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Always return the output string for testing/compatibility
|
|
63
|
+
output_string
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
67
|
+
# This method name will be removed in a future version.
|
|
68
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Railtie to automatically include StyleCapsule helpers in Rails
|
|
5
|
+
if defined?(Rails::Railtie)
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
# Automatically include ERB helper in ActionView::Base (standard Rails pattern)
|
|
8
|
+
# This makes helpers available in all ERB templates automatically
|
|
9
|
+
ActiveSupport.on_load(:action_view) do
|
|
10
|
+
include StyleCapsule::Helper
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Configure CSS file writer for file-based caching
|
|
14
|
+
config.after_initialize do
|
|
15
|
+
# Configure default output directory
|
|
16
|
+
StyleCapsule::CssFileWriter.configure(
|
|
17
|
+
output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
|
|
18
|
+
enabled: true
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Add output directory to asset paths if it exists
|
|
22
|
+
if Rails.application.config.respond_to?(:assets)
|
|
23
|
+
capsules_dir = Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR)
|
|
24
|
+
if Dir.exist?(capsules_dir)
|
|
25
|
+
Rails.application.config.assets.paths << capsules_dir
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Clear CSS caches and stylesheet manifest when classes are unloaded in development
|
|
31
|
+
# This prevents memory leaks from stale cache entries during code reloading
|
|
32
|
+
if Rails.env.development?
|
|
33
|
+
config.to_prepare do
|
|
34
|
+
# Use Rails-friendly class registry instead of ObjectSpace iteration
|
|
35
|
+
# This avoids issues with gems that override Class#name (e.g., Faker)
|
|
36
|
+
StyleCapsule::ClassRegistry.each do |klass|
|
|
37
|
+
# Clear CSS cache if the class has this method
|
|
38
|
+
if klass.method_defined?(:clear_css_cache, false) || klass.private_method_defined?(:clear_css_cache)
|
|
39
|
+
klass.clear_css_cache
|
|
40
|
+
end
|
|
41
|
+
rescue
|
|
42
|
+
# Skip classes that cause errors (unloaded classes, etc.)
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clear stylesheet manifest to allow re-registration during code reload
|
|
47
|
+
StyleCapsule::StylesheetRegistry.clear_manifest
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Load rake tasks for CSS file building
|
|
52
|
+
rake_tasks do
|
|
53
|
+
load File.expand_path("../tasks/style_capsule.rake", __dir__)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Note: PhlexHelper should be included explicitly in ApplicationComponent
|
|
57
|
+
# or your base Phlex component class, not automatically via Railtie
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -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
|