style_capsule 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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