style_capsule 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest/sha1"
5
+
6
+ module StyleCapsule
7
+ # Writes inline CSS to files for HTTP caching
8
+ #
9
+ # This allows inline CSS to be cached by browsers and CDNs, improving performance.
10
+ # Files are written to a configurable output directory and can be precompiled
11
+ # via Rails asset pipeline.
12
+ #
13
+ # @example Configuration
14
+ # StyleCapsule::CssFileWriter.configure(
15
+ # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
16
+ # filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" }
17
+ # )
18
+ #
19
+ # @example Usage
20
+ # file_path = StyleCapsule::CssFileWriter.write_css(
21
+ # css_content: ".section { color: red; }",
22
+ # component_class: MyComponent,
23
+ # capsule_id: "abc123"
24
+ # )
25
+ # # => "capsules/capsule-abc123"
26
+ class CssFileWriter
27
+ # Default output directory for CSS files (relative to Rails root)
28
+ DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
29
+
30
+ class << self
31
+ attr_accessor :output_dir, :filename_pattern, :enabled
32
+
33
+ # Configure CSS file writer
34
+ #
35
+ # @param output_dir [String, Pathname] Directory to write CSS files (relative to Rails root or absolute)
36
+ # Default: `StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR`
37
+ # @param filename_pattern [Proc, nil] Proc to generate filename
38
+ # Receives: (component_class, capsule_id) and should return filename string
39
+ # Default: `"capsule-#{capsule_id}.css"` (capsule_id is unique and deterministic)
40
+ # @param enabled [Boolean] Whether file writing is enabled (default: true)
41
+ # @example
42
+ # StyleCapsule::CssFileWriter.configure(
43
+ # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
44
+ # filename_pattern: ->(klass, capsule) { "capsule-#{capsule}.css" }
45
+ # )
46
+ # @example Custom pattern with component name
47
+ # StyleCapsule::CssFileWriter.configure(
48
+ # filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
49
+ # )
50
+ def configure(output_dir: nil, filename_pattern: nil, enabled: true)
51
+ @enabled = enabled
52
+
53
+ @output_dir = if output_dir
54
+ output_dir.is_a?(Pathname) ? output_dir : Pathname.new(output_dir.to_s)
55
+ elsif defined?(Rails) && Rails.root
56
+ Rails.root.join(DEFAULT_OUTPUT_DIR)
57
+ else
58
+ Pathname.new(DEFAULT_OUTPUT_DIR)
59
+ end
60
+
61
+ @filename_pattern = filename_pattern || default_filename_pattern
62
+ end
63
+
64
+ # Write CSS content to file
65
+ #
66
+ # @param css_content [String] CSS content to write
67
+ # @param component_class [Class] Component class that generated the CSS
68
+ # @param capsule_id [String] Capsule ID for the component
69
+ # @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled
70
+ def write_css(css_content:, component_class:, capsule_id:)
71
+ return nil unless enabled?
72
+
73
+ ensure_output_directory
74
+
75
+ filename = generate_filename(component_class, capsule_id)
76
+ file_path = output_directory.join(filename)
77
+
78
+ # Write CSS to file with explicit UTF-8 encoding
79
+ File.write(file_path, css_content, encoding: "UTF-8")
80
+
81
+ # Return relative path for stylesheet_link_tag
82
+ # Path should be relative to app/assets
83
+ # Handle case where output directory is not under rails_assets_root (e.g., in tests)
84
+ begin
85
+ file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
86
+ rescue ArgumentError
87
+ # If paths don't share a common prefix (e.g., in tests), return just the filename
88
+ filename.gsub(/\.css$/, "")
89
+ end
90
+ end
91
+
92
+ # Check if file exists for given component and capsule
93
+ #
94
+ # @param component_class [Class] Component class
95
+ # @param capsule_id [String] Capsule ID
96
+ # @return [Boolean]
97
+ def file_exists?(component_class:, capsule_id:)
98
+ return false unless enabled?
99
+
100
+ filename = generate_filename(component_class, capsule_id)
101
+ file_path = output_directory.join(filename)
102
+ File.exist?(file_path)
103
+ end
104
+
105
+ # Get file path for given component and capsule
106
+ #
107
+ # @param component_class [Class] Component class
108
+ # @param capsule_id [String] Capsule ID
109
+ # @return [String, nil] Relative file path or nil if disabled
110
+ def file_path_for(component_class:, capsule_id:)
111
+ return nil unless enabled?
112
+
113
+ filename = generate_filename(component_class, capsule_id)
114
+ file_path = output_directory.join(filename)
115
+
116
+ return nil unless File.exist?(file_path)
117
+
118
+ # Return relative path for stylesheet_link_tag
119
+ # Handle case where output directory is not under rails_assets_root (e.g., in tests)
120
+ begin
121
+ file_path.relative_path_from(rails_assets_root).to_s.gsub(/\.css$/, "")
122
+ rescue ArgumentError
123
+ # If paths don't share a common prefix (e.g., in tests), return just the filename
124
+ filename.gsub(/\.css$/, "")
125
+ end
126
+ end
127
+
128
+ # Ensure output directory exists
129
+ #
130
+ # @return [void]
131
+ def ensure_output_directory
132
+ return unless enabled?
133
+
134
+ dir = output_directory
135
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
136
+ end
137
+
138
+ # Clear all generated CSS files
139
+ #
140
+ # @return [void]
141
+ def clear_files
142
+ return unless enabled?
143
+
144
+ dir = output_directory
145
+ return unless Dir.exist?(dir)
146
+
147
+ Dir.glob(dir.join("*.css")).each { |file| File.delete(file) }
148
+ end
149
+
150
+ # Check if file writing is enabled
151
+ #
152
+ # @return [Boolean]
153
+ def enabled?
154
+ @enabled != false
155
+ end
156
+
157
+ private
158
+
159
+ # Get output directory (with default)
160
+ def output_directory
161
+ @output_dir ||= if defined?(Rails) && Rails.root
162
+ Rails.root.join(DEFAULT_OUTPUT_DIR)
163
+ else
164
+ Pathname.new(DEFAULT_OUTPUT_DIR)
165
+ end
166
+ end
167
+
168
+ # Generate filename using pattern
169
+ #
170
+ # @param component_class [Class] Component class
171
+ # @param capsule_id [String] Capsule ID
172
+ # @return [String] Validated filename
173
+ # @raise [SecurityError] If filename contains path traversal or invalid characters
174
+ def generate_filename(component_class, capsule_id)
175
+ pattern_result = filename_pattern.call(component_class, capsule_id)
176
+ # Ensure .css extension
177
+ filename = pattern_result.end_with?(".css") ? pattern_result : "#{pattern_result}.css"
178
+
179
+ # Validate filename to prevent path traversal attacks
180
+ validate_filename!(filename)
181
+
182
+ filename
183
+ end
184
+
185
+ # Default filename pattern
186
+ #
187
+ # Uses only the capsule_id since it's unique and deterministic.
188
+ # The capsule_id is generated from the component class name using SHA1,
189
+ # ensuring uniqueness while keeping filenames concise and not exposing
190
+ # internal component structure.
191
+ #
192
+ # @return [Proc] Proc that generates filename from (component_class, capsule_id)
193
+ def default_filename_pattern
194
+ ->(component_class, capsule_id) do
195
+ # Validate capsule_id is safe (alphanumeric, hyphens, underscores only)
196
+ # This ensures the filename is safe even if capsule_id generation changes
197
+ safe_capsule_id = capsule_id.to_s.gsub(/[^a-zA-Z0-9_-]/, "")
198
+
199
+ if safe_capsule_id.empty?
200
+ raise ArgumentError, "Invalid capsule_id: must contain at least one alphanumeric character"
201
+ end
202
+
203
+ "capsule-#{safe_capsule_id}.css"
204
+ end
205
+ end
206
+
207
+ # Get Rails assets root (app/assets)
208
+ def rails_assets_root
209
+ if defined?(Rails) && Rails.root
210
+ Rails.root.join("app/assets")
211
+ else
212
+ Pathname.new("app/assets")
213
+ end
214
+ end
215
+
216
+ # Validate filename to prevent path traversal and other security issues
217
+ #
218
+ # @param filename [String] Filename to validate
219
+ # @raise [SecurityError] If filename is invalid
220
+ def validate_filename!(filename)
221
+ # Reject path traversal attempts
222
+ if filename.include?("..") || filename.include?("/") || filename.include?("\\")
223
+ raise SecurityError, "Invalid filename: path traversal detected in '#{filename}'"
224
+ end
225
+
226
+ # Reject null bytes
227
+ if filename.include?("\0")
228
+ raise SecurityError, "Invalid filename: null byte detected"
229
+ end
230
+
231
+ # Ensure filename is reasonable length (filesystem limit is typically 255)
232
+ if filename.length > 255
233
+ raise SecurityError, "Invalid filename: too long (max 255 characters, got #{filename.length})"
234
+ end
235
+
236
+ # Ensure filename contains only safe characters (alphanumeric, dots, hyphens, underscores)
237
+ # Must end with .css extension
238
+ unless filename.match?(/\A[a-zA-Z0-9._-]+\.css\z/)
239
+ raise SecurityError, "Invalid filename: contains unsafe characters (only alphanumeric, dots, hyphens, underscores allowed, must end with .css)"
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Shared CSS processing logic for scoping selectors with attribute selectors
5
+ #
6
+ # Supports two scoping strategies:
7
+ # 1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
8
+ # - Better browser support (all modern browsers)
9
+ # - Requires CSS parsing and transformation
10
+ # 2. CSS nesting (optional): Wraps entire CSS in [data-capsule="..."] { ... }
11
+ # - More performant (no parsing needed)
12
+ # - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
13
+ module CssProcessor
14
+ # Maximum CSS content size (1MB) to prevent DoS attacks
15
+ MAX_CSS_SIZE = 1_000_000
16
+
17
+ # Rewrite CSS selectors to include attribute-based scoping
18
+ #
19
+ # Transforms:
20
+ # .section { color: red; }
21
+ # .heading:hover { opacity: 0.8; }
22
+ #
23
+ # Into:
24
+ # [data-capsule="a1b2c3d4"] .section { color: red; }
25
+ # [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
26
+ #
27
+ # This approach uses attribute selectors (similar to Angular's Emulated View Encapsulation)
28
+ # instead of renaming classes, ensuring styles only apply within scoped components.
29
+ #
30
+ # Simple approach:
31
+ # - Strips CSS comments first to avoid interference
32
+ # - Finds selectors before opening braces and prefixes them
33
+ # - Handles @media queries (preserves them, scopes inner selectors)
34
+ # - Handles :host and :host-context (component-scoped selectors)
35
+ #
36
+ # @param css_string [String] Original CSS content
37
+ # @param capsule_id [String] The capsule ID to use in attribute selector
38
+ # @return [String] CSS with scoped selectors
39
+ # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
40
+ def self.scope_selectors(css_string, capsule_id)
41
+ return css_string if css_string.nil? || css_string.strip.empty?
42
+
43
+ # Validate CSS size to prevent DoS attacks
44
+ if css_string.bytesize > MAX_CSS_SIZE
45
+ raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
46
+ end
47
+
48
+ # Validate capsule_id
49
+ validate_capsule_id!(capsule_id)
50
+
51
+ css = css_string.dup
52
+ capsule_attr = %([data-capsule="#{capsule_id}"])
53
+
54
+ # Strip CSS comments to avoid interference with selector matching
55
+ # Simple approach: remove /* ... */ comments (including multi-line)
56
+ css_without_comments = strip_comments(css)
57
+
58
+ # Process CSS rule by rule
59
+ # Match: selector(s) { ... }
60
+ # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
61
+ # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
62
+ # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
63
+ css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
64
+ prefix = Regexp.last_match(1) # Previous closing brace or start
65
+ whitespace = Regexp.last_match(2) # Whitespace between rules
66
+ selectors_raw = Regexp.last_match(3) # The selector group
67
+ selectors = selectors_raw.strip # Stripped for processing
68
+ opening_brace = Regexp.last_match(4) # The opening brace
69
+
70
+ # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
71
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
72
+
73
+ # Skip if already scoped (avoid double-scoping)
74
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
75
+
76
+ # Skip empty selectors
77
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
78
+
79
+ # Split selectors by comma and scope each one
80
+ scoped_selectors = selectors.split(",").map do |selector|
81
+ selector = selector.strip
82
+ next selector if selector.empty?
83
+
84
+ # Handle special component-scoped selectors (:host, :host-context)
85
+ if selector.start_with?(":host")
86
+ selector = selector
87
+ .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
88
+ .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
89
+ .gsub(/^:host\b/, capsule_attr)
90
+ selector
91
+ else
92
+ # Add capsule attribute with space before selector for descendant matching
93
+ # This ensures styles apply to elements inside the scoped wrapper
94
+ "#{capsule_attr} #{selector}"
95
+ end
96
+ end.compact.join(", ")
97
+
98
+ "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
99
+ end
100
+
101
+ # Restore comments in their original positions
102
+ # Since we stripped comments, we need to put them back
103
+ # For simplicity, we'll just return the processed CSS without comments
104
+ # (comments are typically removed in production CSS anyway)
105
+ css_without_comments
106
+ end
107
+
108
+ # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
109
+ #
110
+ # This approach is more performant as it requires no CSS parsing or transformation.
111
+ # However, it requires CSS nesting support in browsers (Chrome 112+, Firefox 117+, Safari 16.5+).
112
+ #
113
+ # Transforms:
114
+ # .section { color: red; }
115
+ # .heading:hover { opacity: 0.8; }
116
+ #
117
+ # Into:
118
+ # [data-capsule="a1b2c3d4"] {
119
+ # .section { color: red; }
120
+ # .heading:hover { opacity: 0.8; }
121
+ # }
122
+ #
123
+ # @param css_string [String] Original CSS content
124
+ # @param capsule_id [String] The capsule ID to use in attribute selector
125
+ # @return [String] CSS wrapped in nesting selector
126
+ # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
127
+ def self.scope_with_nesting(css_string, capsule_id)
128
+ return css_string if css_string.nil? || css_string.strip.empty?
129
+
130
+ # Validate CSS size to prevent DoS attacks
131
+ if css_string.bytesize > MAX_CSS_SIZE
132
+ raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
133
+ end
134
+
135
+ # Validate capsule_id
136
+ validate_capsule_id!(capsule_id)
137
+
138
+ # Simply wrap the entire CSS in the capsule attribute selector
139
+ # No parsing or transformation needed - much more performant
140
+ capsule_attr = %([data-capsule="#{capsule_id}"])
141
+ "#{capsule_attr} {\n#{css_string}\n}"
142
+ end
143
+
144
+ # Strip CSS comments (/* ... */) from the string
145
+ #
146
+ # @param css [String] CSS content with comments
147
+ # @return [String] CSS content without comments
148
+ def self.strip_comments(css)
149
+ # Remove /* ... */ comments (including multi-line)
150
+ # Use non-greedy match to handle multiple comments
151
+ css.gsub(/\/\*.*?\*\//m, "")
152
+ end
153
+
154
+ # Validate capsule ID to prevent injection attacks
155
+ #
156
+ # @param capsule_id [String] Capsule ID to validate
157
+ # @raise [ArgumentError] If capsule_id is invalid
158
+ def self.validate_capsule_id!(capsule_id)
159
+ unless capsule_id.is_a?(String)
160
+ raise ArgumentError, "capsule_id must be a String (got #{capsule_id.class})"
161
+ end
162
+
163
+ # Must not be empty (check first for clearer error message)
164
+ if capsule_id.empty?
165
+ raise ArgumentError, "capsule_id cannot be empty"
166
+ end
167
+
168
+ # Capsule ID should only contain alphanumeric characters, hyphens, and underscores
169
+ # This prevents injection into HTML attributes
170
+ unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
171
+ raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
172
+ end
173
+
174
+ # Reasonable length limit
175
+ if capsule_id.length > 100
176
+ raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
177
+ end
178
+ end
179
+
180
+ private_class_method :strip_comments, :validate_capsule_id!
181
+ end
182
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "active_support/core_ext/string"
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 (similar to javascript_importmap_tags)
152
+ #
153
+ # Usage in ERB:
154
+ # <%= stylesheet_registrymap_tags %>
155
+ # <%= stylesheet_registrymap_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_registrymap_tags(namespace: nil)
160
+ StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,66 @@
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 (similar to javascript_importmap_tags)
40
+ #
41
+ # Usage in Phlex layouts:
42
+ # head do
43
+ # stylesheet_registrymap_tags
44
+ # stylesheet_registrymap_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_registrymap_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
+ end
66
+ end