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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/.clang-tidy +30 -0
  3. data/.github/workflows/ci-macos.yml +12 -0
  4. data/.github/workflows/ci.yml +77 -0
  5. data/.github/workflows/test.yml +76 -0
  6. data/.gitignore +45 -0
  7. data/.overcommit.yml +38 -0
  8. data/.rubocop.yml +83 -0
  9. data/BENCHMARKS.md +201 -0
  10. data/CHANGELOG.md +1 -0
  11. data/Gemfile +27 -0
  12. data/LICENSE +21 -0
  13. data/RAGEL_MIGRATION.md +60 -0
  14. data/README.md +292 -0
  15. data/Rakefile +209 -0
  16. data/benchmarks/benchmark_harness.rb +193 -0
  17. data/benchmarks/benchmark_merging.rb +121 -0
  18. data/benchmarks/benchmark_optimization_comparison.rb +168 -0
  19. data/benchmarks/benchmark_parsing.rb +153 -0
  20. data/benchmarks/benchmark_ragel_removal.rb +56 -0
  21. data/benchmarks/benchmark_runner.rb +70 -0
  22. data/benchmarks/benchmark_serialization.rb +180 -0
  23. data/benchmarks/benchmark_shorthand.rb +109 -0
  24. data/benchmarks/benchmark_shorthand_expansion.rb +176 -0
  25. data/benchmarks/benchmark_specificity.rb +124 -0
  26. data/benchmarks/benchmark_string_allocation.rb +151 -0
  27. data/benchmarks/benchmark_stylesheet_to_s.rb +62 -0
  28. data/benchmarks/benchmark_to_s_cached.rb +55 -0
  29. data/benchmarks/benchmark_value_splitter.rb +54 -0
  30. data/benchmarks/benchmark_yjit.rb +158 -0
  31. data/benchmarks/benchmark_yjit_workers.rb +61 -0
  32. data/benchmarks/profile_to_s.rb +23 -0
  33. data/benchmarks/speedup_calculator.rb +83 -0
  34. data/benchmarks/system_metadata.rb +81 -0
  35. data/benchmarks/templates/benchmarks.md.erb +221 -0
  36. data/benchmarks/yjit_tests.rb +141 -0
  37. data/cataract.gemspec +34 -0
  38. data/cliff.toml +92 -0
  39. data/examples/color_conversion_visual_test/color_conversion_test.html +3603 -0
  40. data/examples/color_conversion_visual_test/generate.rb +202 -0
  41. data/examples/color_conversion_visual_test/template.html.erb +259 -0
  42. data/examples/css_analyzer/analyzer.rb +164 -0
  43. data/examples/css_analyzer/analyzers/base.rb +33 -0
  44. data/examples/css_analyzer/analyzers/colors.rb +133 -0
  45. data/examples/css_analyzer/analyzers/important.rb +88 -0
  46. data/examples/css_analyzer/analyzers/properties.rb +61 -0
  47. data/examples/css_analyzer/analyzers/specificity.rb +68 -0
  48. data/examples/css_analyzer/templates/report.html.erb +575 -0
  49. data/examples/css_analyzer.rb +69 -0
  50. data/examples/github_analysis.html +5343 -0
  51. data/ext/cataract/cataract.c +1086 -0
  52. data/ext/cataract/cataract.h +174 -0
  53. data/ext/cataract/css_parser.c +1435 -0
  54. data/ext/cataract/extconf.rb +48 -0
  55. data/ext/cataract/import_scanner.c +174 -0
  56. data/ext/cataract/merge.c +973 -0
  57. data/ext/cataract/shorthand_expander.c +902 -0
  58. data/ext/cataract/specificity.c +213 -0
  59. data/ext/cataract/value_splitter.c +116 -0
  60. data/ext/cataract_color/cataract_color.c +16 -0
  61. data/ext/cataract_color/color_conversion.c +1687 -0
  62. data/ext/cataract_color/color_conversion.h +136 -0
  63. data/ext/cataract_color/color_conversion_lab.c +571 -0
  64. data/ext/cataract_color/color_conversion_named.c +259 -0
  65. data/ext/cataract_color/color_conversion_oklab.c +547 -0
  66. data/ext/cataract_color/extconf.rb +23 -0
  67. data/ext/cataract_old/cataract.c +393 -0
  68. data/ext/cataract_old/cataract.h +250 -0
  69. data/ext/cataract_old/css_parser.c +933 -0
  70. data/ext/cataract_old/extconf.rb +67 -0
  71. data/ext/cataract_old/import_scanner.c +174 -0
  72. data/ext/cataract_old/merge.c +776 -0
  73. data/ext/cataract_old/shorthand_expander.c +902 -0
  74. data/ext/cataract_old/specificity.c +213 -0
  75. data/ext/cataract_old/stylesheet.c +290 -0
  76. data/ext/cataract_old/value_splitter.c +116 -0
  77. data/lib/cataract/at_rule.rb +97 -0
  78. data/lib/cataract/color_conversion.rb +18 -0
  79. data/lib/cataract/declarations.rb +332 -0
  80. data/lib/cataract/import_resolver.rb +210 -0
  81. data/lib/cataract/rule.rb +131 -0
  82. data/lib/cataract/stylesheet.rb +716 -0
  83. data/lib/cataract/stylesheet_scope.rb +257 -0
  84. data/lib/cataract/version.rb +5 -0
  85. data/lib/cataract.rb +107 -0
  86. data/lib/tasks/gem.rake +158 -0
  87. data/scripts/fuzzer/run.rb +828 -0
  88. data/scripts/fuzzer/worker.rb +99 -0
  89. data/scripts/generate_benchmarks_md.rb +155 -0
  90. 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