style_capsule 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.md +22 -0
- data/README.md +398 -0
- data/SECURITY.md +55 -0
- data/lib/style_capsule/component.rb +485 -0
- data/lib/style_capsule/component_styles_support.rb +73 -0
- data/lib/style_capsule/css_file_writer.rb +244 -0
- data/lib/style_capsule/css_processor.rb +182 -0
- data/lib/style_capsule/helper.rb +163 -0
- data/lib/style_capsule/phlex_helper.rb +66 -0
- data/lib/style_capsule/railtie.rb +68 -0
- data/lib/style_capsule/stylesheet_registry.rb +494 -0
- data/lib/style_capsule/version.rb +5 -0
- data/lib/style_capsule/view_component.rb +479 -0
- data/lib/style_capsule/view_component_helper.rb +53 -0
- data/lib/style_capsule.rb +93 -0
- data/lib/tasks/style_capsule.rake +89 -0
- data/sig/style_capsule.rbs +110 -0
- metadata +305 -0
|
@@ -0,0 +1,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
|