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,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Builds CSS files from StyleCapsule components
|
|
5
|
+
#
|
|
6
|
+
# This class extracts the logic from the rake task so it can be tested independently.
|
|
7
|
+
# The rake task delegates to this class.
|
|
8
|
+
class ComponentBuilder
|
|
9
|
+
class << self
|
|
10
|
+
# Check if Phlex is available
|
|
11
|
+
# This method can be stubbed in tests to test fallback paths
|
|
12
|
+
def phlex_available?
|
|
13
|
+
!!defined?(Phlex::HTML)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if ViewComponent is available
|
|
17
|
+
# This method can be stubbed in tests to test fallback paths
|
|
18
|
+
def view_component_available?
|
|
19
|
+
!!defined?(ViewComponent::Base)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Find all Phlex components that use StyleCapsule
|
|
23
|
+
#
|
|
24
|
+
# Uses ClassRegistry to find registered components (Rails-friendly, avoids expensive ObjectSpace scanning).
|
|
25
|
+
# Classes are automatically registered when they include StyleCapsule::Component.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Class>] Array of component classes
|
|
28
|
+
def find_phlex_components
|
|
29
|
+
return [] unless phlex_available?
|
|
30
|
+
|
|
31
|
+
components = []
|
|
32
|
+
|
|
33
|
+
# Use the registry (Rails-friendly, avoids expensive ObjectSpace scanning)
|
|
34
|
+
ClassRegistry.each do |klass|
|
|
35
|
+
if klass < Phlex::HTML && klass.included_modules.include?(StyleCapsule::Component)
|
|
36
|
+
components << klass
|
|
37
|
+
end
|
|
38
|
+
rescue
|
|
39
|
+
# Skip classes that cause errors (inheritance checks, etc.)
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
components
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Find all ViewComponent components that use StyleCapsule
|
|
47
|
+
#
|
|
48
|
+
# Uses ClassRegistry to find registered components (Rails-friendly, avoids expensive ObjectSpace scanning).
|
|
49
|
+
# Classes are automatically registered when they include StyleCapsule::ViewComponent.
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Class>] Array of component classes
|
|
52
|
+
def find_view_components
|
|
53
|
+
return [] unless view_component_available?
|
|
54
|
+
|
|
55
|
+
components = []
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
# Use the registry (Rails-friendly, avoids expensive ObjectSpace scanning)
|
|
59
|
+
ClassRegistry.each do |klass|
|
|
60
|
+
if klass < ViewComponent::Base && klass.included_modules.include?(StyleCapsule::ViewComponent)
|
|
61
|
+
components << klass
|
|
62
|
+
end
|
|
63
|
+
rescue
|
|
64
|
+
# Skip classes that cause errors (inheritance checks, etc.)
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
rescue
|
|
68
|
+
# ViewComponent may have loading issues (e.g., version compatibility)
|
|
69
|
+
# Silently skip ViewComponent components if there's an error
|
|
70
|
+
# This allows the rake task to continue with Phlex components
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
components
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Collect all component classes that use StyleCapsule
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<Class>] Array of component classes
|
|
79
|
+
def collect_components
|
|
80
|
+
find_phlex_components + find_view_components
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Build CSS file for a single component
|
|
84
|
+
#
|
|
85
|
+
# @param component_class [Class] Component class to build
|
|
86
|
+
# @param output_proc [Proc, nil] Optional proc to call with output messages
|
|
87
|
+
# @return [String, nil] Generated file path or nil if skipped
|
|
88
|
+
def build_component(component_class, output_proc: nil)
|
|
89
|
+
return nil unless component_class.inline_cache_strategy == :file
|
|
90
|
+
# Check for class method component_styles (required for file caching)
|
|
91
|
+
return nil unless component_class.respond_to?(:component_styles, false)
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
# Use class method component_styles for file caching
|
|
95
|
+
css_content = component_class.component_styles
|
|
96
|
+
return nil if css_content.nil? || css_content.to_s.strip.empty?
|
|
97
|
+
|
|
98
|
+
# Create a temporary instance to get capsule
|
|
99
|
+
# Some components might require arguments, so we catch errors
|
|
100
|
+
instance = component_class.new
|
|
101
|
+
capsule_id = instance.component_capsule
|
|
102
|
+
scoped_css = instance.send(:scope_css, css_content)
|
|
103
|
+
|
|
104
|
+
# Write CSS file
|
|
105
|
+
file_path = CssFileWriter.write_css(
|
|
106
|
+
css_content: scoped_css,
|
|
107
|
+
component_class: component_class,
|
|
108
|
+
capsule_id: capsule_id
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
output_proc&.call("Generated: #{file_path}") if file_path
|
|
112
|
+
file_path
|
|
113
|
+
rescue ArgumentError, NoMethodError => e
|
|
114
|
+
# Component requires arguments or has dependencies - skip it
|
|
115
|
+
output_proc&.call("Skipped #{component_class.name}: #{e.message}")
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Build CSS files for all components
|
|
121
|
+
#
|
|
122
|
+
# @param output_proc [Proc, nil] Optional proc to call with output messages
|
|
123
|
+
# @return [Integer] Number of files generated
|
|
124
|
+
def build_all(output_proc: nil)
|
|
125
|
+
require "style_capsule/css_file_writer"
|
|
126
|
+
|
|
127
|
+
# Ensure output directory exists
|
|
128
|
+
CssFileWriter.ensure_output_directory
|
|
129
|
+
|
|
130
|
+
# Collect all component classes that use StyleCapsule
|
|
131
|
+
component_classes = collect_components
|
|
132
|
+
|
|
133
|
+
# Generate CSS files for each component
|
|
134
|
+
generated_count = 0
|
|
135
|
+
component_classes.each do |component_class|
|
|
136
|
+
file_path = build_component(component_class, output_proc: output_proc)
|
|
137
|
+
generated_count += 1 if file_path
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
output_proc&.call("StyleCapsule CSS files built successfully")
|
|
141
|
+
generated_count
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Shared module for component styles support (used by both Component and ViewComponent)
|
|
5
|
+
#
|
|
6
|
+
# Provides unified support for both instance and class method component_styles:
|
|
7
|
+
# - Instance method: `def component_styles` - dynamic rendering, supports all cache strategies except :file
|
|
8
|
+
# - Class method: `def self.component_styles` - static rendering, supports all cache strategies including :file
|
|
9
|
+
module ComponentStylesSupport
|
|
10
|
+
# Check if component defines styles (instance or class method)
|
|
11
|
+
#
|
|
12
|
+
# @return [Boolean]
|
|
13
|
+
def component_styles?
|
|
14
|
+
instance_styles? || class_styles?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Resolve component styles (from instance or class method)
|
|
18
|
+
#
|
|
19
|
+
# @return [String, nil] CSS content or nil if no styles defined
|
|
20
|
+
def component_styles_content
|
|
21
|
+
# Prefer instance method for dynamic rendering
|
|
22
|
+
if instance_styles?
|
|
23
|
+
component_styles
|
|
24
|
+
elsif class_styles?
|
|
25
|
+
self.class.component_styles
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if component uses class method styles exclusively (required for file caching)
|
|
30
|
+
#
|
|
31
|
+
# Returns true only if class styles are defined and instance styles are not.
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def class_styles_only?
|
|
35
|
+
class_styles? && !instance_styles?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if file caching is allowed for this component
|
|
39
|
+
#
|
|
40
|
+
# File caching is only allowed for class method component_styles
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def file_caching_allowed?
|
|
44
|
+
cache_strategy = self.class.inline_cache_strategy
|
|
45
|
+
return false unless cache_strategy == :file
|
|
46
|
+
class_styles_only?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Check if component defines instance method styles
|
|
52
|
+
#
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def instance_styles?
|
|
55
|
+
return false unless respond_to?(:component_styles, true)
|
|
56
|
+
styles = component_styles
|
|
57
|
+
styles && !styles.to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if component defines class method styles
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def class_styles?
|
|
64
|
+
return false unless self.class.respond_to?(:component_styles, false)
|
|
65
|
+
begin
|
|
66
|
+
styles = self.class.component_styles
|
|
67
|
+
styles && !styles.to_s.strip.empty?
|
|
68
|
+
rescue NoMethodError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,250 @@
|
|
|
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 rails_available?
|
|
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
|
+
# Check if Rails is available
|
|
158
|
+
# This method can be stubbed in tests to test fallback paths
|
|
159
|
+
def rails_available?
|
|
160
|
+
defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Get output directory (with default)
|
|
166
|
+
def output_directory
|
|
167
|
+
@output_dir ||= if rails_available?
|
|
168
|
+
Rails.root.join(DEFAULT_OUTPUT_DIR)
|
|
169
|
+
else
|
|
170
|
+
Pathname.new(DEFAULT_OUTPUT_DIR)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Generate filename using pattern
|
|
175
|
+
#
|
|
176
|
+
# @param component_class [Class] Component class
|
|
177
|
+
# @param capsule_id [String] Capsule ID
|
|
178
|
+
# @return [String] Validated filename
|
|
179
|
+
# @raise [SecurityError] If filename contains path traversal or invalid characters
|
|
180
|
+
def generate_filename(component_class, capsule_id)
|
|
181
|
+
pattern_result = filename_pattern.call(component_class, capsule_id)
|
|
182
|
+
# Ensure .css extension
|
|
183
|
+
filename = pattern_result.end_with?(".css") ? pattern_result : "#{pattern_result}.css"
|
|
184
|
+
|
|
185
|
+
# Validate filename to prevent path traversal attacks
|
|
186
|
+
validate_filename!(filename)
|
|
187
|
+
|
|
188
|
+
filename
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Default filename pattern
|
|
192
|
+
#
|
|
193
|
+
# Uses only the capsule_id since it's unique and deterministic.
|
|
194
|
+
# The capsule_id is generated from the component class name using SHA1,
|
|
195
|
+
# ensuring uniqueness while keeping filenames concise and not exposing
|
|
196
|
+
# internal component structure.
|
|
197
|
+
#
|
|
198
|
+
# @return [Proc] Proc that generates filename from (component_class, capsule_id)
|
|
199
|
+
def default_filename_pattern
|
|
200
|
+
->(component_class, capsule_id) do
|
|
201
|
+
# Validate capsule_id is safe (alphanumeric, hyphens, underscores only)
|
|
202
|
+
# This ensures the filename is safe even if capsule_id generation changes
|
|
203
|
+
safe_capsule_id = capsule_id.to_s.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
204
|
+
|
|
205
|
+
if safe_capsule_id.empty?
|
|
206
|
+
raise ArgumentError, "Invalid capsule_id: must contain at least one alphanumeric character"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
"capsule-#{safe_capsule_id}.css"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Get Rails assets root (app/assets)
|
|
214
|
+
def rails_assets_root
|
|
215
|
+
if rails_available?
|
|
216
|
+
Rails.root.join("app/assets")
|
|
217
|
+
else
|
|
218
|
+
Pathname.new("app/assets")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Validate filename to prevent path traversal and other security issues
|
|
223
|
+
#
|
|
224
|
+
# @param filename [String] Filename to validate
|
|
225
|
+
# @raise [SecurityError] If filename is invalid
|
|
226
|
+
def validate_filename!(filename)
|
|
227
|
+
# Reject path traversal attempts
|
|
228
|
+
if filename.include?("..") || filename.include?("/") || filename.include?("\\")
|
|
229
|
+
raise SecurityError, "Invalid filename: path traversal detected in '#{filename}'"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Reject null bytes
|
|
233
|
+
if filename.include?("\0")
|
|
234
|
+
raise SecurityError, "Invalid filename: null byte detected"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Ensure filename is reasonable length (filesystem limit is typically 255)
|
|
238
|
+
if filename.length > 255
|
|
239
|
+
raise SecurityError, "Invalid filename: too long (max 255 characters, got #{filename.length})"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Ensure filename contains only safe characters (alphanumeric, dots, hyphens, underscores)
|
|
243
|
+
# Must end with .css extension
|
|
244
|
+
unless filename.match?(/\A[a-zA-Z0-9._-]+\.css\z/)
|
|
245
|
+
raise SecurityError, "Invalid filename: contains unsafe characters (only alphanumeric, dots, hyphens, underscores allowed, must end with .css)"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
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
|