cataract 0.1.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/.clang-tidy +30 -0
- data/.github/workflows/ci-macos.yml +12 -0
- data/.github/workflows/ci.yml +77 -0
- data/.github/workflows/test.yml +76 -0
- data/.gitignore +45 -0
- data/.overcommit.yml +38 -0
- data/.rubocop.yml +83 -0
- data/BENCHMARKS.md +201 -0
- data/CHANGELOG.md +1 -0
- data/Gemfile +27 -0
- data/LICENSE +21 -0
- data/RAGEL_MIGRATION.md +60 -0
- data/README.md +292 -0
- data/Rakefile +209 -0
- data/benchmarks/benchmark_harness.rb +193 -0
- data/benchmarks/benchmark_merging.rb +121 -0
- data/benchmarks/benchmark_optimization_comparison.rb +168 -0
- data/benchmarks/benchmark_parsing.rb +153 -0
- data/benchmarks/benchmark_ragel_removal.rb +56 -0
- data/benchmarks/benchmark_runner.rb +70 -0
- data/benchmarks/benchmark_serialization.rb +180 -0
- data/benchmarks/benchmark_shorthand.rb +109 -0
- data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
- data/benchmarks/benchmark_specificity.rb +124 -0
- data/benchmarks/benchmark_string_allocation.rb +151 -0
- data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
- data/benchmarks/benchmark_to_s_cached.rb +55 -0
- data/benchmarks/benchmark_value_splitter.rb +54 -0
- data/benchmarks/benchmark_yjit.rb +158 -0
- data/benchmarks/benchmark_yjit_workers.rb +61 -0
- data/benchmarks/profile_to_s.rb +23 -0
- data/benchmarks/speedup_calculator.rb +83 -0
- data/benchmarks/system_metadata.rb +81 -0
- data/benchmarks/templates/benchmarks.md.erb +221 -0
- data/benchmarks/yjit_tests.rb +141 -0
- data/cataract.gemspec +34 -0
- data/cliff.toml +92 -0
- data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
- data/examples/color_conversion_visual_test/generate.rb +202 -0
- data/examples/color_conversion_visual_test/template.html.erb +259 -0
- data/examples/css_analyzer/analyzer.rb +164 -0
- data/examples/css_analyzer/analyzers/base.rb +33 -0
- data/examples/css_analyzer/analyzers/colors.rb +133 -0
- data/examples/css_analyzer/analyzers/important.rb +88 -0
- data/examples/css_analyzer/analyzers/properties.rb +61 -0
- data/examples/css_analyzer/analyzers/specificity.rb +68 -0
- data/examples/css_analyzer/templates/report.html.erb +575 -0
- data/examples/css_analyzer.rb +69 -0
- data/examples/github_analysis.html +5343 -0
- data/ext/cataract/cataract.c +1086 -0
- data/ext/cataract/cataract.h +174 -0
- data/ext/cataract/css_parser.c +1435 -0
- data/ext/cataract/extconf.rb +48 -0
- data/ext/cataract/import_scanner.c +174 -0
- data/ext/cataract/merge.c +973 -0
- data/ext/cataract/shorthand_expander.c +902 -0
- data/ext/cataract/specificity.c +213 -0
- data/ext/cataract/value_splitter.c +116 -0
- data/ext/cataract_color/cataract_color.c +16 -0
- data/ext/cataract_color/color_conversion.c +1687 -0
- data/ext/cataract_color/color_conversion.h +136 -0
- data/ext/cataract_color/color_conversion_lab.c +571 -0
- data/ext/cataract_color/color_conversion_named.c +259 -0
- data/ext/cataract_color/color_conversion_oklab.c +547 -0
- data/ext/cataract_color/extconf.rb +23 -0
- data/ext/cataract_old/cataract.c +393 -0
- data/ext/cataract_old/cataract.h +250 -0
- data/ext/cataract_old/css_parser.c +933 -0
- data/ext/cataract_old/extconf.rb +67 -0
- data/ext/cataract_old/import_scanner.c +174 -0
- data/ext/cataract_old/merge.c +776 -0
- data/ext/cataract_old/shorthand_expander.c +902 -0
- data/ext/cataract_old/specificity.c +213 -0
- data/ext/cataract_old/stylesheet.c +290 -0
- data/ext/cataract_old/value_splitter.c +116 -0
- data/lib/cataract/at_rule.rb +97 -0
- data/lib/cataract/color_conversion.rb +18 -0
- data/lib/cataract/declarations.rb +332 -0
- data/lib/cataract/import_resolver.rb +210 -0
- data/lib/cataract/rule.rb +131 -0
- data/lib/cataract/stylesheet.rb +716 -0
- data/lib/cataract/stylesheet_scope.rb +257 -0
- data/lib/cataract/version.rb +5 -0
- data/lib/cataract.rb +107 -0
- data/lib/tasks/gem.rake +158 -0
- data/scripts/fuzzer/run.rb +828 -0
- data/scripts/fuzzer/worker.rb +99 -0
- data/scripts/generate_benchmarks_md.rb +155 -0
- metadata +135 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cataract
|
|
4
|
+
# Represents a CSS at-rule like @keyframes, @font-face, @supports, etc.
|
|
5
|
+
#
|
|
6
|
+
# AtRule is a C struct defined as: `Struct.new(:id, :selector, :content, :specificity)`
|
|
7
|
+
#
|
|
8
|
+
# At-rules define CSS resources or control structures rather than selecting elements.
|
|
9
|
+
# Unlike regular rules, they don't have CSS specificity and are filtered out when
|
|
10
|
+
# using `select(&:selector?)`.
|
|
11
|
+
#
|
|
12
|
+
# The content field varies by at-rule type:
|
|
13
|
+
# - `@keyframes`: Array of Rule (keyframe percentage blocks like "0%", "100%")
|
|
14
|
+
# - `@font-face`: Array of Declaration (font property declarations)
|
|
15
|
+
# - `@supports`: Array of Rule (conditional rules)
|
|
16
|
+
#
|
|
17
|
+
# @example Parse @keyframes
|
|
18
|
+
# css = "@keyframes fade { 0% { opacity: 0; } 100% { opacity: 1; } }"
|
|
19
|
+
# sheet = Cataract.parse_css(css)
|
|
20
|
+
# at_rule = sheet.rules.first
|
|
21
|
+
# at_rule.selector #=> "@keyframes fade"
|
|
22
|
+
# at_rule.content #=> [Rule, Rule] (two keyframe blocks)
|
|
23
|
+
#
|
|
24
|
+
# @example Parse @font-face
|
|
25
|
+
# css = "@font-face { font-family: 'MyFont'; src: url('font.woff'); }"
|
|
26
|
+
# sheet = Cataract.parse_css(css)
|
|
27
|
+
# at_rule = sheet.rules.first
|
|
28
|
+
# at_rule.selector #=> "@font-face"
|
|
29
|
+
# at_rule.content #=> [Declaration, Declaration]
|
|
30
|
+
#
|
|
31
|
+
# @attr [Integer] id The at-rule's position in the stylesheet (0-indexed)
|
|
32
|
+
# @attr [String] selector The at-rule identifier (e.g., "@keyframes fade", "@font-face")
|
|
33
|
+
# @attr [Array<Rule>, Array<Declaration>] content Nested rules or declarations
|
|
34
|
+
# @attr [nil] specificity Always nil for at-rules (they don't have CSS specificity)
|
|
35
|
+
class AtRule
|
|
36
|
+
# Check if this is a selector-based rule (vs an at-rule like @keyframes).
|
|
37
|
+
#
|
|
38
|
+
# @return [Boolean] Always returns false for AtRule objects
|
|
39
|
+
def selector?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if this is an at-rule.
|
|
44
|
+
#
|
|
45
|
+
# @return [Boolean] Always returns true for AtRule objects
|
|
46
|
+
def at_rule?
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if this is a specific at-rule type.
|
|
51
|
+
#
|
|
52
|
+
# @param type [Symbol] At-rule type (e.g., :keyframes, :font_face)
|
|
53
|
+
# @return [Boolean] true if at-rule matches the type
|
|
54
|
+
#
|
|
55
|
+
# @example Check for @keyframes
|
|
56
|
+
# at_rule.at_rule_type?(:keyframes) #=> true if selector is "@keyframes ..."
|
|
57
|
+
#
|
|
58
|
+
# @example Check for @font-face
|
|
59
|
+
# at_rule.at_rule_type?(:font_face) #=> true if selector is "@font-face"
|
|
60
|
+
def at_rule_type?(type)
|
|
61
|
+
type_str = "@#{type.to_s.tr('_', '-')}"
|
|
62
|
+
selector.start_with?(type_str)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if this at-rule has a declaration with the specified property.
|
|
66
|
+
#
|
|
67
|
+
# @param _property [String] CSS property name
|
|
68
|
+
# @param _value [String, nil] Optional value to match
|
|
69
|
+
# @return [Boolean] Always returns false for AtRule objects
|
|
70
|
+
def has_property?(_property, _value = nil)
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if this at-rule has any !important declarations.
|
|
75
|
+
#
|
|
76
|
+
# @param _property [String, nil] Optional property name
|
|
77
|
+
# @return [Boolean] Always returns false for AtRule objects
|
|
78
|
+
def has_important?(_property = nil)
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Compare at-rules by their attributes rather than object identity.
|
|
83
|
+
#
|
|
84
|
+
# Two at-rules are equal if they have the same id, selector, and content.
|
|
85
|
+
#
|
|
86
|
+
# @param other [Object] Object to compare with
|
|
87
|
+
# @return [Boolean] true if at-rules have same attributes
|
|
88
|
+
def ==(other)
|
|
89
|
+
return false unless other.is_a?(AtRule)
|
|
90
|
+
|
|
91
|
+
id == other.id &&
|
|
92
|
+
selector == other.selector &&
|
|
93
|
+
content == other.content
|
|
94
|
+
end
|
|
95
|
+
alias eql? ==
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Color conversion utilities for Cataract
|
|
4
|
+
#
|
|
5
|
+
# This is an optional extension that adds color conversion capabilities to Cataract::Stylesheet.
|
|
6
|
+
# Load it explicitly to add the convert_colors! method:
|
|
7
|
+
#
|
|
8
|
+
# require 'cataract/color_conversion'
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# sheet = Cataract.parse_css('.button { color: #ff0000; }')
|
|
12
|
+
# sheet.convert_colors!(to: :rgb)
|
|
13
|
+
# sheet.to_css # => ".button { color: rgb(255 0 0); }"
|
|
14
|
+
#
|
|
15
|
+
# This extension is loaded on-demand to reduce memory footprint for users who
|
|
16
|
+
# don't need color conversion functionality.
|
|
17
|
+
|
|
18
|
+
require 'cataract/cataract_color'
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cataract
|
|
4
|
+
# Container for CSS property declarations with merge and cascade support.
|
|
5
|
+
#
|
|
6
|
+
# The Declarations class provides a convenient Ruby interface for working with
|
|
7
|
+
# CSS property-value pairs. It wraps an array of Declaration structs (defined in C)
|
|
8
|
+
# and provides hash-like access, iteration, and merging capabilities.
|
|
9
|
+
#
|
|
10
|
+
# @example Create from hash
|
|
11
|
+
# decls = Cataract::Declarations.new('color' => 'red', 'margin' => '10px')
|
|
12
|
+
# decls['color'] #=> "red"
|
|
13
|
+
#
|
|
14
|
+
# @example Create from Declaration array
|
|
15
|
+
# rule = Cataract.parse_css("body { color: red; }").rules.first
|
|
16
|
+
# decls = Cataract::Declarations.new(rule.declarations)
|
|
17
|
+
# decls['color'] #=> "red"
|
|
18
|
+
#
|
|
19
|
+
# @example Create from CSS string
|
|
20
|
+
# decls = Cataract::Declarations.new("color: red; margin: 10px")
|
|
21
|
+
# decls.size #=> 2
|
|
22
|
+
#
|
|
23
|
+
# @example Work with !important
|
|
24
|
+
# decls = Cataract::Declarations.new('color' => 'red !important')
|
|
25
|
+
# decls.important?('color') #=> true
|
|
26
|
+
# decls['color'] #=> "red !important"
|
|
27
|
+
class Declarations
|
|
28
|
+
include Enumerable
|
|
29
|
+
|
|
30
|
+
# Create a new Declarations container.
|
|
31
|
+
#
|
|
32
|
+
# @param properties [Hash, Array<Declaration>, String] Initial declarations
|
|
33
|
+
# - Hash: Property name => value pairs
|
|
34
|
+
# - Array: Array of Declaration structs from parser
|
|
35
|
+
# - String: CSS declaration block (e.g., "color: red; margin: 10px")
|
|
36
|
+
# @return [Declarations] New Declarations instance
|
|
37
|
+
def initialize(properties = {})
|
|
38
|
+
case properties
|
|
39
|
+
when Array
|
|
40
|
+
# Array of Declaration structs from C parser - store directly
|
|
41
|
+
@values = properties
|
|
42
|
+
when Hash
|
|
43
|
+
# Hash from user - convert to internal storage
|
|
44
|
+
@values = []
|
|
45
|
+
properties.each { |prop, value| self[prop] = value }
|
|
46
|
+
when String
|
|
47
|
+
# String "color: red; background: blue" - parse it
|
|
48
|
+
@values = parse_declaration_string(properties)
|
|
49
|
+
else
|
|
50
|
+
@values = []
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Get the value of a CSS property.
|
|
55
|
+
#
|
|
56
|
+
# Returns the property value with !important suffix if present.
|
|
57
|
+
# Property names are case-insensitive.
|
|
58
|
+
#
|
|
59
|
+
# @param property [String, Symbol] The CSS property name
|
|
60
|
+
# @return [String, nil] The property value with !important suffix, or nil if not found
|
|
61
|
+
#
|
|
62
|
+
# @example
|
|
63
|
+
# decls['color'] #=> "red"
|
|
64
|
+
# decls['Color'] #=> "red" (case-insensitive)
|
|
65
|
+
# decls['font-weight'] #=> "bold !important"
|
|
66
|
+
def get_property(property)
|
|
67
|
+
prop = normalize_property(property)
|
|
68
|
+
val = find_value(prop)
|
|
69
|
+
return nil if val.nil?
|
|
70
|
+
|
|
71
|
+
suffix = val.important ? ' !important' : ''
|
|
72
|
+
"#{val.value}#{suffix}"
|
|
73
|
+
end
|
|
74
|
+
alias [] get_property
|
|
75
|
+
|
|
76
|
+
# Set the value of a CSS property.
|
|
77
|
+
#
|
|
78
|
+
# Property names are normalized to lowercase. Trailing semicolons are stripped.
|
|
79
|
+
# The !important flag can be included in the value string.
|
|
80
|
+
#
|
|
81
|
+
# @param property [String, Symbol] The CSS property name
|
|
82
|
+
# @param value [String] The property value (may include !important)
|
|
83
|
+
# @return [void]
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# decls['color'] = 'red'
|
|
87
|
+
# decls['margin'] = '10px !important'
|
|
88
|
+
# decls['Color'] = 'blue' # Overwrites 'color' (case-insensitive)
|
|
89
|
+
def set_property(property, value)
|
|
90
|
+
prop = normalize_property(property)
|
|
91
|
+
|
|
92
|
+
# Parse !important and strip trailing semicolons (css_parser compatibility)
|
|
93
|
+
clean_value = value.to_s.strip
|
|
94
|
+
# Remove trailing semicolons (guard to avoid allocation when no semicolon present)
|
|
95
|
+
# value_str = value_str.sub(/;+$/, '') if value_str.end_with?(';')
|
|
96
|
+
clean_value.sub!(/;+$/, '') if clean_value.end_with?(';')
|
|
97
|
+
|
|
98
|
+
is_important = clean_value.end_with?('!important')
|
|
99
|
+
if is_important
|
|
100
|
+
clean_value.sub!(/\s*!important\s*$/, '').strip!
|
|
101
|
+
else
|
|
102
|
+
clean_value.strip!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Reject malformed declarations with no value (e.g., "color: !important")
|
|
106
|
+
# css_parser silently ignores these
|
|
107
|
+
return if clean_value.empty?
|
|
108
|
+
|
|
109
|
+
# Find existing value or create new one
|
|
110
|
+
# Properties from C parser are already normalized, so direct comparison
|
|
111
|
+
existing_index = @values.find_index { |v| v.property == prop }
|
|
112
|
+
|
|
113
|
+
# Create a new Declaration struct
|
|
114
|
+
new_val = Cataract::Declaration.new(prop, clean_value, is_important)
|
|
115
|
+
|
|
116
|
+
if existing_index
|
|
117
|
+
@values[existing_index] = new_val
|
|
118
|
+
else
|
|
119
|
+
@values << new_val
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
alias []= set_property
|
|
123
|
+
|
|
124
|
+
# Check if a property is defined in this declaration block.
|
|
125
|
+
#
|
|
126
|
+
# @param property [String, Symbol] The CSS property name
|
|
127
|
+
# @return [Boolean] true if the property exists
|
|
128
|
+
#
|
|
129
|
+
# @example
|
|
130
|
+
# decls.key?('color') #=> true
|
|
131
|
+
# decls.has_property?('font-size') #=> false
|
|
132
|
+
def key?(property)
|
|
133
|
+
!find_value(normalize_property(property)).nil?
|
|
134
|
+
end
|
|
135
|
+
alias has_property? key?
|
|
136
|
+
|
|
137
|
+
# Check if a property has the !important flag.
|
|
138
|
+
#
|
|
139
|
+
# @param property [String, Symbol] The CSS property name
|
|
140
|
+
# @return [Boolean] true if the property has !important, false otherwise
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# decls['color'] = 'red !important'
|
|
144
|
+
# decls.important?('color') #=> true
|
|
145
|
+
# decls['margin'] = '10px'
|
|
146
|
+
# decls.important?('margin') #=> false
|
|
147
|
+
def important?(property)
|
|
148
|
+
val = find_value(normalize_property(property))
|
|
149
|
+
val ? val.important : false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Delete a property from the declaration block.
|
|
153
|
+
#
|
|
154
|
+
# @param property [String, Symbol] The CSS property name to delete
|
|
155
|
+
# @return [Array<Declaration>] The modified declarations array
|
|
156
|
+
#
|
|
157
|
+
# @example
|
|
158
|
+
# decls.delete('color')
|
|
159
|
+
# decls.key?('color') #=> false
|
|
160
|
+
def delete(property)
|
|
161
|
+
prop = normalize_property(property)
|
|
162
|
+
@values.delete_if { |v| v.property == prop }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Iterate through each property-value pair.
|
|
166
|
+
#
|
|
167
|
+
# @yieldparam property [String] The property name
|
|
168
|
+
# @yieldparam value [String] The property value (without !important)
|
|
169
|
+
# @yieldparam important [Boolean] Whether the property has !important flag
|
|
170
|
+
# @return [Enumerator, nil] Returns enumerator if no block given
|
|
171
|
+
#
|
|
172
|
+
# @example
|
|
173
|
+
# decls.each do |property, value, important|
|
|
174
|
+
# puts "#{property}: #{value}#{important ? ' !important' : ''}"
|
|
175
|
+
# end
|
|
176
|
+
def each
|
|
177
|
+
return enum_for(:each) unless block_given?
|
|
178
|
+
|
|
179
|
+
@values.each do |val|
|
|
180
|
+
yield val.property, val.value, val.important
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Get the number of declarations.
|
|
185
|
+
#
|
|
186
|
+
# @return [Integer] Number of properties in the declaration block
|
|
187
|
+
def size
|
|
188
|
+
@values.size
|
|
189
|
+
end
|
|
190
|
+
alias length size
|
|
191
|
+
|
|
192
|
+
# Check if the declaration block is empty.
|
|
193
|
+
#
|
|
194
|
+
# @return [Boolean] true if no properties are defined
|
|
195
|
+
def empty?
|
|
196
|
+
@values.empty?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Convert declarations to CSS string.
|
|
200
|
+
#
|
|
201
|
+
# Implemented in C for performance (see ext/cataract/cataract.c).
|
|
202
|
+
# The C implementation is defined via rb_define_method and overrides this stub.
|
|
203
|
+
#
|
|
204
|
+
# @return [String] CSS declaration block string
|
|
205
|
+
#
|
|
206
|
+
# @example
|
|
207
|
+
# decls.to_s #=> "color: red; margin: 10px !important;"
|
|
208
|
+
|
|
209
|
+
# Enable implicit string conversion for comparisons
|
|
210
|
+
alias to_str to_s
|
|
211
|
+
|
|
212
|
+
# Convert to a hash of property => value pairs.
|
|
213
|
+
#
|
|
214
|
+
# Values include !important suffix if present.
|
|
215
|
+
#
|
|
216
|
+
# @return [Hash<String, String>] Hash of property names to values
|
|
217
|
+
#
|
|
218
|
+
# @example
|
|
219
|
+
# decls.to_h #=> {"color" => "red", "margin" => "10px !important"}
|
|
220
|
+
def to_h
|
|
221
|
+
result = {}
|
|
222
|
+
each do |property, value, is_important|
|
|
223
|
+
suffix = is_important ? ' !important' : ''
|
|
224
|
+
result[property] = "#{value}#{suffix}"
|
|
225
|
+
end
|
|
226
|
+
result
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Convert to an array of Declaration structs.
|
|
230
|
+
#
|
|
231
|
+
# Returns the internal array of Declaration structs, which is useful
|
|
232
|
+
# for creating Rule objects or passing to C functions.
|
|
233
|
+
#
|
|
234
|
+
# @return [Array<Declaration>] Array of Declaration structs
|
|
235
|
+
def to_a
|
|
236
|
+
@values
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Merge another set of declarations into this one (mutating).
|
|
240
|
+
#
|
|
241
|
+
# Properties from the other declarations will overwrite properties in this one.
|
|
242
|
+
# The !important flag is preserved during merge.
|
|
243
|
+
#
|
|
244
|
+
# @param other [Declarations, Hash] Declarations to merge in
|
|
245
|
+
# @return [self] Returns self for method chaining
|
|
246
|
+
# @raise [ArgumentError] If other is not Declarations or Hash
|
|
247
|
+
#
|
|
248
|
+
# @example
|
|
249
|
+
# decls1 = Cataract::Declarations.new('color' => 'red')
|
|
250
|
+
# decls2 = Cataract::Declarations.new('margin' => '10px')
|
|
251
|
+
# decls1.merge!(decls2)
|
|
252
|
+
# decls1.to_h #=> {"color" => "red", "margin" => "10px"}
|
|
253
|
+
def merge!(other)
|
|
254
|
+
case other
|
|
255
|
+
when Declarations
|
|
256
|
+
other.each { |prop, value, important| self[prop] = important ? "#{value} !important" : value }
|
|
257
|
+
when Hash
|
|
258
|
+
other.each { |prop, value| self[prop] = value }
|
|
259
|
+
else
|
|
260
|
+
raise ArgumentError, 'Can only merge Declarations or Hash objects'
|
|
261
|
+
end
|
|
262
|
+
self
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Merge another set of declarations (non-mutating).
|
|
266
|
+
#
|
|
267
|
+
# Creates a copy of this Declarations object and merges the other into it.
|
|
268
|
+
#
|
|
269
|
+
# @param other [Declarations, Hash] Declarations to merge
|
|
270
|
+
# @return [Declarations] New Declarations with merged properties
|
|
271
|
+
#
|
|
272
|
+
# @example
|
|
273
|
+
# decls1 = Cataract::Declarations.new('color' => 'red')
|
|
274
|
+
# decls2 = Cataract::Declarations.new('margin' => '10px')
|
|
275
|
+
# merged = decls1.merge(decls2)
|
|
276
|
+
# merged.to_h #=> {"color" => "red", "margin" => "10px"}
|
|
277
|
+
# decls1.to_h #=> {"color" => "red"} (unchanged)
|
|
278
|
+
def merge(other)
|
|
279
|
+
dup.merge!(other)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Create a shallow copy of this Declarations object.
|
|
283
|
+
#
|
|
284
|
+
# @return [Declarations] New Declarations with copied properties
|
|
285
|
+
def dup
|
|
286
|
+
new_decl = self.class.new
|
|
287
|
+
each { |prop, value, important| new_decl[prop] = important ? "#{value} !important" : value }
|
|
288
|
+
new_decl
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Compare this Declarations with another object.
|
|
292
|
+
#
|
|
293
|
+
# @param other [Declarations, String] Object to compare with
|
|
294
|
+
# @return [Boolean] true if equal
|
|
295
|
+
#
|
|
296
|
+
# @example
|
|
297
|
+
# decls1 = Cataract::Declarations.new('color' => 'red')
|
|
298
|
+
# decls2 = Cataract::Declarations.new('color' => 'red')
|
|
299
|
+
# decls1 == decls2 #=> true
|
|
300
|
+
# decls1 == "color: red;" #=> true (string comparison)
|
|
301
|
+
def ==(other)
|
|
302
|
+
case other
|
|
303
|
+
when Declarations
|
|
304
|
+
# Compare arrays of Declaration structs
|
|
305
|
+
to_a == other.to_a
|
|
306
|
+
when String
|
|
307
|
+
# Allow string comparison for convenience
|
|
308
|
+
to_s == other
|
|
309
|
+
else
|
|
310
|
+
false
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# Normalize user-provided property names for case-insensitive lookup
|
|
317
|
+
# Note: Properties from C parser are already normalized
|
|
318
|
+
def normalize_property(property)
|
|
319
|
+
property.to_s.strip.downcase
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Find a Value struct by normalized property name
|
|
323
|
+
def find_value(normalized_property)
|
|
324
|
+
@values.find { |v| v.property == normalized_property }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Parse "color: red; background: blue" string into array of Declaration structs
|
|
328
|
+
def parse_declaration_string(str)
|
|
329
|
+
Cataract.parse_declarations(str)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'open-uri'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module Cataract
|
|
8
|
+
# Error raised during import resolution
|
|
9
|
+
class ImportError < Error; end
|
|
10
|
+
|
|
11
|
+
# Resolves @import statements in CSS
|
|
12
|
+
# Handles fetching imported files and inlining them with proper security controls
|
|
13
|
+
module ImportResolver
|
|
14
|
+
# Default options for safe import resolution
|
|
15
|
+
SAFE_DEFAULTS = {
|
|
16
|
+
max_depth: 5, # Prevent infinite recursion
|
|
17
|
+
allowed_schemes: ['https'], # Only HTTPS by default
|
|
18
|
+
extensions: ['css'], # Only .css files
|
|
19
|
+
timeout: 10, # 10 second timeout for fetches
|
|
20
|
+
follow_redirects: true, # Follow redirects
|
|
21
|
+
base_path: nil # Base path for resolving relative imports
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Resolve @import statements in CSS
|
|
25
|
+
#
|
|
26
|
+
# @param css [String] CSS content with @import statements
|
|
27
|
+
# @param options [Hash] Import resolution options
|
|
28
|
+
# @param depth [Integer] Current recursion depth (internal)
|
|
29
|
+
# @param imported_urls [Set] Set of already imported URLs to prevent circular references
|
|
30
|
+
# @return [String] CSS with imports inlined
|
|
31
|
+
def self.resolve(css, options = {}, depth: 0, imported_urls: Set.new)
|
|
32
|
+
# Normalize options
|
|
33
|
+
opts = normalize_options(options)
|
|
34
|
+
|
|
35
|
+
# Check recursion depth
|
|
36
|
+
# depth starts at 0, max_depth is count of imports allowed
|
|
37
|
+
# depth 0: parsing main file (counts as import 1)
|
|
38
|
+
# depth 1: parsing first @import (counts as import 2)
|
|
39
|
+
# depth 2: parsing nested @import (counts as import 3)
|
|
40
|
+
if depth > opts[:max_depth]
|
|
41
|
+
raise ImportError, "Import nesting too deep: exceeded maximum depth of #{opts[:max_depth]}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Find all @import statements at the top of the file
|
|
45
|
+
# Per CSS spec, @import must come before all rules except @charset
|
|
46
|
+
imports = extract_imports(css)
|
|
47
|
+
|
|
48
|
+
return css if imports.empty?
|
|
49
|
+
|
|
50
|
+
# Process each import
|
|
51
|
+
resolved_css = +'' # Mutable string
|
|
52
|
+
remaining_css = css
|
|
53
|
+
|
|
54
|
+
imports.each do |import_data|
|
|
55
|
+
url = import_data[:url]
|
|
56
|
+
media = import_data[:media]
|
|
57
|
+
|
|
58
|
+
# Validate URL
|
|
59
|
+
validate_url(url, opts)
|
|
60
|
+
|
|
61
|
+
# Check for circular references
|
|
62
|
+
raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
|
|
63
|
+
|
|
64
|
+
# Fetch imported CSS
|
|
65
|
+
imported_css = fetch_url(url, opts)
|
|
66
|
+
|
|
67
|
+
# Recursively resolve imports in the imported CSS
|
|
68
|
+
imported_urls_copy = imported_urls.dup
|
|
69
|
+
imported_urls_copy.add(url)
|
|
70
|
+
imported_css = resolve(imported_css, opts, depth: depth + 1, imported_urls: imported_urls_copy)
|
|
71
|
+
|
|
72
|
+
# Wrap in @media if import had media query
|
|
73
|
+
imported_css = "@media #{media} {\n#{imported_css}\n}" if media
|
|
74
|
+
|
|
75
|
+
resolved_css << imported_css << "\n"
|
|
76
|
+
|
|
77
|
+
# Remove this import from remaining CSS
|
|
78
|
+
remaining_css = remaining_css.sub(import_data[:full_match], '')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return resolved imports + remaining CSS
|
|
82
|
+
resolved_css + remaining_css
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Normalize options with safe defaults
|
|
86
|
+
def self.normalize_options(options)
|
|
87
|
+
if options == true
|
|
88
|
+
# imports: true -> use safe defaults
|
|
89
|
+
SAFE_DEFAULTS.dup
|
|
90
|
+
elsif options.is_a?(Hash)
|
|
91
|
+
# imports: { ... } -> merge with safe defaults
|
|
92
|
+
SAFE_DEFAULTS.merge(options)
|
|
93
|
+
else
|
|
94
|
+
raise ArgumentError, 'imports option must be true or a Hash'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Extract @import statements from CSS
|
|
99
|
+
# Returns array of hashes: { url: "...", media: "...", full_match: "..." }
|
|
100
|
+
# Delegates to C implementation for performance
|
|
101
|
+
def self.extract_imports(css)
|
|
102
|
+
Cataract.extract_imports(css)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Normalize URL - handle relative paths and missing schemes
|
|
106
|
+
# Returns a URI object
|
|
107
|
+
def self.normalize_url(url, base_path = nil)
|
|
108
|
+
# Try to parse as-is first
|
|
109
|
+
uri = URI.parse(url)
|
|
110
|
+
|
|
111
|
+
# If no scheme, treat as relative file path
|
|
112
|
+
if uri.scheme.nil?
|
|
113
|
+
# Convert to file:// URL
|
|
114
|
+
# Relative paths stay relative, absolute paths stay absolute
|
|
115
|
+
if url.start_with?('/')
|
|
116
|
+
uri = URI.parse("file://#{url}")
|
|
117
|
+
else
|
|
118
|
+
# Relative path - make it absolute relative to base_path or current directory
|
|
119
|
+
absolute_path = if base_path
|
|
120
|
+
File.expand_path(url, base_path)
|
|
121
|
+
else
|
|
122
|
+
File.expand_path(url)
|
|
123
|
+
end
|
|
124
|
+
uri = URI.parse("file://#{absolute_path}")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
uri
|
|
129
|
+
rescue URI::InvalidURIError => e
|
|
130
|
+
raise ImportError, "Invalid import URL: #{url} (#{e.message})"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Validate URL against security options
|
|
134
|
+
def self.validate_url(url, options)
|
|
135
|
+
uri = normalize_url(url, options[:base_path])
|
|
136
|
+
|
|
137
|
+
# Check scheme
|
|
138
|
+
unless options[:allowed_schemes].include?(uri.scheme)
|
|
139
|
+
raise ImportError,
|
|
140
|
+
"Import scheme '#{uri.scheme}' not allowed. Allowed schemes: #{options[:allowed_schemes].join(', ')}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Check extension
|
|
144
|
+
path = uri.path || ''
|
|
145
|
+
ext = File.extname(path).delete_prefix('.')
|
|
146
|
+
|
|
147
|
+
unless ext.empty? || options[:extensions].include?(ext)
|
|
148
|
+
raise ImportError,
|
|
149
|
+
"Import extension '.#{ext}' not allowed. Allowed extensions: #{options[:extensions].join(', ')}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Additional security checks for file:// scheme
|
|
153
|
+
if uri.scheme == 'file'
|
|
154
|
+
# Resolve to absolute path to prevent directory traversal
|
|
155
|
+
file_path = uri.path
|
|
156
|
+
|
|
157
|
+
# Check file exists and is readable
|
|
158
|
+
unless File.exist?(file_path) && File.readable?(file_path)
|
|
159
|
+
raise ImportError, "Import file not found or not readable: #{file_path}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Prevent reading sensitive files (basic check)
|
|
163
|
+
dangerous_paths = ['/etc/', '/proc/', '/sys/', '/dev/']
|
|
164
|
+
if dangerous_paths.any? { |prefix| file_path.start_with?(prefix) }
|
|
165
|
+
raise ImportError, "Import of sensitive system files not allowed: #{file_path}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
true
|
|
170
|
+
rescue URI::InvalidURIError => e
|
|
171
|
+
raise ImportError, "Invalid import URL: #{url} (#{e.message})"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Fetch content from URL
|
|
175
|
+
def self.fetch_url(url, options)
|
|
176
|
+
uri = normalize_url(url, options[:base_path])
|
|
177
|
+
|
|
178
|
+
case uri.scheme
|
|
179
|
+
when 'file'
|
|
180
|
+
# Read from local filesystem
|
|
181
|
+
File.read(uri.path)
|
|
182
|
+
when 'http', 'https'
|
|
183
|
+
# Fetch from network
|
|
184
|
+
fetch_http(uri, options)
|
|
185
|
+
else
|
|
186
|
+
raise ImportError, "Unsupported scheme: #{uri.scheme}"
|
|
187
|
+
end
|
|
188
|
+
rescue Errno::ENOENT
|
|
189
|
+
raise ImportError, "Import file not found: #{url}"
|
|
190
|
+
rescue OpenURI::HTTPError => e
|
|
191
|
+
raise ImportError, "HTTP error fetching import: #{url} (#{e.message})"
|
|
192
|
+
rescue SocketError => e
|
|
193
|
+
raise ImportError, "Network error fetching import: #{url} (#{e.message})"
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
raise ImportError, "Error fetching import: #{url} (#{e.class}: #{e.message})"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Fetch content via HTTP/HTTPS
|
|
199
|
+
def self.fetch_http(uri, options)
|
|
200
|
+
# Use open-uri with timeout
|
|
201
|
+
open_uri_options = {
|
|
202
|
+
read_timeout: options[:timeout],
|
|
203
|
+
redirect: options[:follow_redirects]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Use uri.open instead of URI.open to avoid shell command injection
|
|
207
|
+
uri.open(open_uri_options, &:read)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|