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,716 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cataract
|
|
4
|
+
# Represents a parsed CSS stylesheet with rule management and merging capabilities.
|
|
5
|
+
#
|
|
6
|
+
# The Stylesheet class stores parsed CSS rules in a flat array structure that preserves
|
|
7
|
+
# insertion order. Media queries are tracked via an index that maps media query symbols
|
|
8
|
+
# to rule IDs, allowing efficient filtering and serialization.
|
|
9
|
+
#
|
|
10
|
+
# @example Parse and query CSS
|
|
11
|
+
# sheet = Cataract::Stylesheet.parse("body { color: red; }")
|
|
12
|
+
# sheet.size #=> 1
|
|
13
|
+
# sheet.rules.first.selector #=> "body"
|
|
14
|
+
#
|
|
15
|
+
# @example Work with media queries
|
|
16
|
+
# sheet = Cataract.parse_css("@media print { .footer { color: blue; } }")
|
|
17
|
+
# sheet.media_queries #=> [:print]
|
|
18
|
+
# sheet.with_media(:print).first.selector #=> ".footer"
|
|
19
|
+
#
|
|
20
|
+
# @attr_reader [Array<Rule>] rules Array of parsed CSS rules
|
|
21
|
+
# @attr_reader [String, nil] charset The @charset declaration if present
|
|
22
|
+
class Stylesheet
|
|
23
|
+
include Enumerable
|
|
24
|
+
|
|
25
|
+
# @return [Array<Rule>] Array of parsed CSS rules
|
|
26
|
+
attr_reader :rules
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] The @charset declaration if present
|
|
29
|
+
attr_reader :charset
|
|
30
|
+
|
|
31
|
+
# Create a new empty stylesheet.
|
|
32
|
+
#
|
|
33
|
+
# @param options [Hash] Configuration options
|
|
34
|
+
# @option options [Boolean, Hash] :import (false) Enable @import resolution.
|
|
35
|
+
# Pass true for defaults, or a hash with:
|
|
36
|
+
# - :allowed_schemes [Array<String>] URI schemes to allow (default: ['https'])
|
|
37
|
+
# - :extensions [Array<String>] File extensions to allow (default: ['css'])
|
|
38
|
+
# - :max_depth [Integer] Maximum import nesting (default: 5)
|
|
39
|
+
# - :base_path [String] Base directory for relative imports
|
|
40
|
+
# @option options [Boolean] :io_exceptions (true) Whether to raise exceptions
|
|
41
|
+
# on I/O errors (file not found, network errors, etc.)
|
|
42
|
+
def initialize(options = {})
|
|
43
|
+
@options = {
|
|
44
|
+
import: false,
|
|
45
|
+
io_exceptions: true
|
|
46
|
+
}.merge(options)
|
|
47
|
+
|
|
48
|
+
@rules = [] # Flat array of Rule structs
|
|
49
|
+
@_media_index = {} # Hash: Symbol => Array of rule IDs
|
|
50
|
+
@charset = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse CSS and return a new Stylesheet
|
|
54
|
+
#
|
|
55
|
+
# @param css [String] CSS string to parse
|
|
56
|
+
# @param options [Hash] Options passed to Stylesheet.new
|
|
57
|
+
# @return [Stylesheet] Parsed stylesheet
|
|
58
|
+
def self.parse(css, **options)
|
|
59
|
+
sheet = new(options)
|
|
60
|
+
sheet.add_block(css)
|
|
61
|
+
sheet
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Load CSS from a file and return a new Stylesheet.
|
|
65
|
+
#
|
|
66
|
+
# @param filename [String] Path to the CSS file
|
|
67
|
+
# @param base_dir [String] Base directory for resolving the filename (default: '.')
|
|
68
|
+
# @param options [Hash] Options passed to Stylesheet.new
|
|
69
|
+
# @return [Stylesheet] A new Stylesheet containing the parsed CSS
|
|
70
|
+
def self.load_file(filename, base_dir = '.', **options)
|
|
71
|
+
sheet = new(options)
|
|
72
|
+
sheet.load_file(filename, base_dir)
|
|
73
|
+
sheet
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Load CSS from a URI and return a new Stylesheet.
|
|
77
|
+
#
|
|
78
|
+
# @param uri [String] URI to load CSS from (http://, https://, or file://)
|
|
79
|
+
# @param options [Hash] Options passed to Stylesheet.new
|
|
80
|
+
# @return [Stylesheet] A new Stylesheet containing the parsed CSS
|
|
81
|
+
def self.load_uri(uri, **options)
|
|
82
|
+
sheet = new(options)
|
|
83
|
+
sheet.load_uri(uri, options)
|
|
84
|
+
sheet
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Iterate over all rules (required by Enumerable).
|
|
88
|
+
#
|
|
89
|
+
# Yields both selector-based rules (Rule) and at-rules (AtRule).
|
|
90
|
+
# Use rule.selector? to filter for selector-based rules only.
|
|
91
|
+
#
|
|
92
|
+
# @yield [rule] Block to execute for each rule
|
|
93
|
+
# @yieldparam rule [Rule, AtRule] The rule object
|
|
94
|
+
# @return [Enumerator] Returns enumerator if no block given
|
|
95
|
+
#
|
|
96
|
+
# @example Iterate over all rules
|
|
97
|
+
# sheet.each { |rule| puts rule.selector }
|
|
98
|
+
#
|
|
99
|
+
# @example Filter to selector-based rules only
|
|
100
|
+
# sheet.select(&:selector?).each { |rule| puts rule.specificity }
|
|
101
|
+
def each(&)
|
|
102
|
+
return enum_for(:each) unless block_given?
|
|
103
|
+
|
|
104
|
+
@rules.each(&)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Filter rules by media query symbol(s).
|
|
108
|
+
#
|
|
109
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
110
|
+
#
|
|
111
|
+
# @param media [Symbol, Array<Symbol>] Media query symbol(s) to filter by
|
|
112
|
+
# @return [StylesheetScope] Scope with media filter applied
|
|
113
|
+
#
|
|
114
|
+
# @example Get print media rules
|
|
115
|
+
# sheet.with_media(:print).each { |rule| puts rule.selector }
|
|
116
|
+
# sheet.with_media(:print).select(&:selector?).map(&:selector)
|
|
117
|
+
#
|
|
118
|
+
# @example Get rules from multiple media queries
|
|
119
|
+
# sheet.with_media([:screen, :print]).map(&:selector)
|
|
120
|
+
#
|
|
121
|
+
# @example Chain filters
|
|
122
|
+
# sheet.with_media(:print).with_specificity(10..).to_a
|
|
123
|
+
def with_media(media)
|
|
124
|
+
StylesheetScope.new(self, media: media)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Filter rules by CSS specificity.
|
|
128
|
+
#
|
|
129
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
130
|
+
#
|
|
131
|
+
# @param specificity [Integer, Range] Specificity value or range
|
|
132
|
+
# @return [StylesheetScope] Scope with specificity filter applied
|
|
133
|
+
#
|
|
134
|
+
# @example Get high-specificity rules
|
|
135
|
+
# sheet.with_specificity(100..).each { |rule| puts rule.selector }
|
|
136
|
+
#
|
|
137
|
+
# @example Get exact specificity
|
|
138
|
+
# sheet.with_specificity(10).map(&:selector)
|
|
139
|
+
#
|
|
140
|
+
# @example Chain with media filter
|
|
141
|
+
# sheet.with_media(:print).with_specificity(10..50).to_a
|
|
142
|
+
def with_specificity(specificity)
|
|
143
|
+
StylesheetScope.new(self, specificity: specificity)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Filter rules by CSS selector.
|
|
147
|
+
#
|
|
148
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
149
|
+
# Supports both exact string matching and regular expression patterns.
|
|
150
|
+
#
|
|
151
|
+
# @param selector [String, Regexp] CSS selector to match (exact or pattern)
|
|
152
|
+
# @return [StylesheetScope] Scope with selector filter applied
|
|
153
|
+
#
|
|
154
|
+
# @example Find body rules (exact match)
|
|
155
|
+
# sheet.with_selector('body').to_a
|
|
156
|
+
#
|
|
157
|
+
# @example Find all .btn-* classes (pattern match)
|
|
158
|
+
# sheet.with_selector(/\.btn-/).map(&:selector)
|
|
159
|
+
#
|
|
160
|
+
# @example Find body rules in print media
|
|
161
|
+
# sheet.with_media(:print).with_selector('body').each { |r| puts r }
|
|
162
|
+
#
|
|
163
|
+
# @example Chain multiple filters
|
|
164
|
+
# sheet.with_selector('.header').with_specificity(10..).to_a
|
|
165
|
+
def with_selector(selector)
|
|
166
|
+
StylesheetScope.new(self, selector: selector)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Filter rules by CSS property name and optional value.
|
|
170
|
+
#
|
|
171
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
172
|
+
#
|
|
173
|
+
# @param property [String] CSS property name to match
|
|
174
|
+
# @param value [String, nil] Optional property value to match
|
|
175
|
+
# @return [StylesheetScope] Scope with property filter applied
|
|
176
|
+
#
|
|
177
|
+
# @example Find all rules with color property
|
|
178
|
+
# sheet.with_property('color').map(&:selector)
|
|
179
|
+
#
|
|
180
|
+
# @example Find rules with position: absolute
|
|
181
|
+
# sheet.with_property('position', 'absolute').to_a
|
|
182
|
+
#
|
|
183
|
+
# @example Chain with media filter
|
|
184
|
+
# sheet.with_media(:screen).with_property('z-index').to_a
|
|
185
|
+
def with_property(property, value = nil)
|
|
186
|
+
StylesheetScope.new(self, property: property, property_value: value)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Filter to only base rules (rules not inside any @media query).
|
|
190
|
+
#
|
|
191
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
192
|
+
#
|
|
193
|
+
# @return [StylesheetScope] Scope with base_only filter applied
|
|
194
|
+
#
|
|
195
|
+
# @example Get base rules only
|
|
196
|
+
# sheet.base_only.map(&:selector)
|
|
197
|
+
#
|
|
198
|
+
# @example Chain with property filter
|
|
199
|
+
# sheet.base_only.with_property('color').to_a
|
|
200
|
+
def base_only
|
|
201
|
+
StylesheetScope.new(self, base_only: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Filter by at-rule type.
|
|
205
|
+
#
|
|
206
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
207
|
+
#
|
|
208
|
+
# @param type [Symbol] At-rule type to match (:keyframes, :font_face, etc.)
|
|
209
|
+
# @return [StylesheetScope] Scope with at-rule type filter applied
|
|
210
|
+
#
|
|
211
|
+
# @example Find all @keyframes
|
|
212
|
+
# sheet.with_at_rule_type(:keyframes).map(&:selector)
|
|
213
|
+
#
|
|
214
|
+
# @example Find all @font-face
|
|
215
|
+
# sheet.with_at_rule_type(:font_face).to_a
|
|
216
|
+
#
|
|
217
|
+
# @example Chain with media filter
|
|
218
|
+
# sheet.with_media(:screen).with_at_rule_type(:keyframes).to_a
|
|
219
|
+
def with_at_rule_type(type)
|
|
220
|
+
StylesheetScope.new(self, at_rule_type: type)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Filter to rules with !important declarations.
|
|
224
|
+
#
|
|
225
|
+
# Returns a chainable StylesheetScope that can be further filtered.
|
|
226
|
+
#
|
|
227
|
+
# @param property [String, nil] Optional property name to match
|
|
228
|
+
# @return [StylesheetScope] Scope with important filter applied
|
|
229
|
+
#
|
|
230
|
+
# @example Find all rules with any !important
|
|
231
|
+
# sheet.with_important.map(&:selector)
|
|
232
|
+
#
|
|
233
|
+
# @example Find rules with color !important
|
|
234
|
+
# sheet.with_important('color').to_a
|
|
235
|
+
#
|
|
236
|
+
# @example Chain with media filter
|
|
237
|
+
# sheet.with_media(:screen).with_important.to_a
|
|
238
|
+
def with_important(property = nil)
|
|
239
|
+
StylesheetScope.new(self, important: true, important_property: property)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Get all rules without media query (rules that apply to all media)
|
|
243
|
+
#
|
|
244
|
+
# @return [Array<Rule>] Rules with no media query
|
|
245
|
+
def base_rules
|
|
246
|
+
# Rules not in any media_index entry
|
|
247
|
+
media_rule_ids = @_media_index.values.flatten.uniq
|
|
248
|
+
@rules.select.with_index { |_rule, idx| !media_rule_ids.include?(idx) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Get all unique media query symbols
|
|
252
|
+
#
|
|
253
|
+
# @return [Array<Symbol>] Array of unique media query symbols
|
|
254
|
+
def media_queries
|
|
255
|
+
@_media_index.keys
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get all selectors
|
|
259
|
+
#
|
|
260
|
+
# @return [Array<String>] Array of all selectors
|
|
261
|
+
def selectors
|
|
262
|
+
@selectors ||= @rules.map(&:selector)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Serialize to CSS string
|
|
266
|
+
#
|
|
267
|
+
# Converts the stylesheet to a CSS string. Optionally filters output
|
|
268
|
+
# to only include rules from specific media queries.
|
|
269
|
+
#
|
|
270
|
+
# @param media [Symbol, Array<Symbol>] Media type(s) to include (default: :all)
|
|
271
|
+
# - :all - Output all rules including base rules and all media queries
|
|
272
|
+
# - :screen, :print, etc. - Output only rules from specified media query
|
|
273
|
+
# - [:screen, :print] - Output rules from multiple media queries
|
|
274
|
+
#
|
|
275
|
+
# Important: When filtering to specific media types, base rules (rules not
|
|
276
|
+
# inside any @media block) are NOT included. Only rules explicitly inside
|
|
277
|
+
# the requested @media queries are output. Use :all to include base rules.
|
|
278
|
+
# @return [String] CSS string
|
|
279
|
+
#
|
|
280
|
+
# @example Get all CSS
|
|
281
|
+
# sheet.to_s # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
282
|
+
# sheet.to_s(media: :all) # => "body { color: black; } @media print { .footer { color: red; } }"
|
|
283
|
+
#
|
|
284
|
+
# @example Filter to specific media type (excludes base rules)
|
|
285
|
+
# sheet.to_s(media: :print) # => "@media print { .footer { color: red; } }"
|
|
286
|
+
# # Note: base rules like "body { color: black; }" are NOT included
|
|
287
|
+
#
|
|
288
|
+
# @example Filter to multiple media types
|
|
289
|
+
# sheet.to_s(media: [:screen, :print]) # => "@media screen { ... } @media print { ... }"
|
|
290
|
+
def to_s(media: :all)
|
|
291
|
+
which_media = media
|
|
292
|
+
# Normalize to array for consistent filtering
|
|
293
|
+
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
294
|
+
|
|
295
|
+
# If :all is present, return everything (no filtering)
|
|
296
|
+
if which_media_array.include?(:all)
|
|
297
|
+
Cataract._stylesheet_to_s(@rules, @_media_index, @charset, @_has_nesting || false)
|
|
298
|
+
else
|
|
299
|
+
# Collect all rule IDs that match the requested media types
|
|
300
|
+
matching_rule_ids = Set.new
|
|
301
|
+
which_media_array.each do |media_sym|
|
|
302
|
+
if @_media_index[media_sym]
|
|
303
|
+
matching_rule_ids.merge(@_media_index[media_sym])
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
308
|
+
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
309
|
+
|
|
310
|
+
# Build filtered media_index (keep original IDs, just filter to included rules)
|
|
311
|
+
filtered_media_index = {}
|
|
312
|
+
which_media_array.each do |media_sym|
|
|
313
|
+
if @_media_index[media_sym]
|
|
314
|
+
filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# C serialization with filtered data
|
|
319
|
+
# Note: Filtered rules might still contain nesting, so pass the flag
|
|
320
|
+
Cataract._stylesheet_to_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
alias to_css to_s
|
|
324
|
+
|
|
325
|
+
# Serialize to formatted CSS string with indentation and newlines.
|
|
326
|
+
#
|
|
327
|
+
# Converts the stylesheet to a human-readable CSS string with proper indentation.
|
|
328
|
+
# Rules are formatted with each declaration on its own line, and media queries
|
|
329
|
+
# are properly indented. Optionally filters output to specific media queries.
|
|
330
|
+
#
|
|
331
|
+
# @param which_media [Symbol, Array<Symbol>] Optional media filter (default: :all)
|
|
332
|
+
# - :all - Output all rules including base rules and all media queries
|
|
333
|
+
# - :screen, :print, etc. - Output only rules from specified media query
|
|
334
|
+
# - [:screen, :print] - Output rules from multiple media queries
|
|
335
|
+
#
|
|
336
|
+
# @return [String] Formatted CSS string
|
|
337
|
+
#
|
|
338
|
+
# @example Get all CSS formatted
|
|
339
|
+
# sheet.to_formatted_s
|
|
340
|
+
# # => "body {\n color: black;\n}\n@media print {\n .footer {\n color: red;\n }\n}\n"
|
|
341
|
+
#
|
|
342
|
+
# @example Filter to specific media type
|
|
343
|
+
# sheet.to_formatted_s(:print)
|
|
344
|
+
#
|
|
345
|
+
# @see #to_s For compact single-line output
|
|
346
|
+
def to_formatted_s(media: :all)
|
|
347
|
+
which_media = media
|
|
348
|
+
# Normalize to array for consistent filtering
|
|
349
|
+
which_media_array = which_media.is_a?(Array) ? which_media : [which_media]
|
|
350
|
+
|
|
351
|
+
# If :all is present, return everything (no filtering)
|
|
352
|
+
if which_media_array.include?(:all)
|
|
353
|
+
Cataract._stylesheet_to_formatted_s(@rules, @_media_index, @charset, @_has_nesting || false)
|
|
354
|
+
else
|
|
355
|
+
# Collect all rule IDs that match the requested media types
|
|
356
|
+
matching_rule_ids = Set.new
|
|
357
|
+
|
|
358
|
+
# Include rules not in any media query (they apply to all media)
|
|
359
|
+
media_rule_ids = @_media_index.values.flatten.uniq
|
|
360
|
+
all_rule_ids = (0...@rules.length).to_a
|
|
361
|
+
non_media_rule_ids = all_rule_ids - media_rule_ids
|
|
362
|
+
matching_rule_ids.merge(non_media_rule_ids)
|
|
363
|
+
|
|
364
|
+
# Include rules from requested media types
|
|
365
|
+
which_media_array.each do |media_sym|
|
|
366
|
+
if @_media_index[media_sym]
|
|
367
|
+
matching_rule_ids.merge(@_media_index[media_sym])
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Build filtered rules array (keep original IDs, no recreation needed)
|
|
372
|
+
filtered_rules = matching_rule_ids.sort.map! { |rule_id| @rules[rule_id] }
|
|
373
|
+
|
|
374
|
+
# Build filtered media_index (keep original IDs, just filter to included rules)
|
|
375
|
+
filtered_media_index = {}
|
|
376
|
+
which_media_array.each do |media_sym|
|
|
377
|
+
if @_media_index[media_sym]
|
|
378
|
+
filtered_media_index[media_sym] = @_media_index[media_sym] & matching_rule_ids.to_a
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# C serialization with filtered data
|
|
383
|
+
# Note: Filtered rules might still contain nesting, so pass the flag
|
|
384
|
+
Cataract._stylesheet_to_formatted_s(filtered_rules, filtered_media_index, @charset, @_has_nesting || false)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Get number of rules
|
|
389
|
+
#
|
|
390
|
+
# @return [Integer] Number of rules
|
|
391
|
+
def size
|
|
392
|
+
@rules.length
|
|
393
|
+
end
|
|
394
|
+
alias length size
|
|
395
|
+
alias rules_count size
|
|
396
|
+
|
|
397
|
+
# Check if stylesheet is empty
|
|
398
|
+
#
|
|
399
|
+
# @return [Boolean] true if no rules
|
|
400
|
+
def empty?
|
|
401
|
+
@rules.empty?
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Clear all rules
|
|
405
|
+
#
|
|
406
|
+
# @return [self] Returns self for method chaining
|
|
407
|
+
def clear!
|
|
408
|
+
@rules.clear
|
|
409
|
+
@_media_index.clear
|
|
410
|
+
@charset = nil
|
|
411
|
+
@selectors = nil # Clear memoized cache
|
|
412
|
+
self
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Load CSS from a local file and add to this stylesheet.
|
|
416
|
+
#
|
|
417
|
+
# @param filename [String] Path to the CSS file
|
|
418
|
+
# @param base_dir [String] Base directory for resolving the filename (default: '.')
|
|
419
|
+
# @return [self] Returns self for method chaining
|
|
420
|
+
def load_file(filename, base_dir = '.', _media_types = :all)
|
|
421
|
+
# Normalize file path and convert to file:// URI
|
|
422
|
+
file_path = File.expand_path(filename, base_dir)
|
|
423
|
+
file_uri = "file://#{file_path}"
|
|
424
|
+
|
|
425
|
+
# Delegate to load_uri which handles imports and base_path
|
|
426
|
+
load_uri(file_uri)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Load CSS from a URI and add to this stylesheet.
|
|
430
|
+
#
|
|
431
|
+
# @param uri [String] URI to load CSS from (http://, https://, or file://)
|
|
432
|
+
# @param options [Hash] Additional options
|
|
433
|
+
# @return [self] Returns self for method chaining
|
|
434
|
+
def load_uri(uri, options = {})
|
|
435
|
+
require 'uri'
|
|
436
|
+
require 'net/http'
|
|
437
|
+
|
|
438
|
+
uri_obj = URI(uri)
|
|
439
|
+
css_content = nil
|
|
440
|
+
file_path = nil
|
|
441
|
+
|
|
442
|
+
case uri_obj.scheme
|
|
443
|
+
when 'http', 'https'
|
|
444
|
+
response = Net::HTTP.get_response(uri_obj)
|
|
445
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
446
|
+
raise IOError, "Failed to load URI: #{uri} (#{response.code} #{response.message})"
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
css_content = response.body
|
|
450
|
+
when 'file', nil
|
|
451
|
+
# file:// URI or relative path
|
|
452
|
+
path = uri_obj.scheme == 'file' ? uri_obj.path : uri
|
|
453
|
+
# Handle base_uri if provided
|
|
454
|
+
if options[:base_uri]
|
|
455
|
+
base = URI(options[:base_uri])
|
|
456
|
+
path = File.join(base.path, path) if base.scheme == 'file' || base.scheme.nil?
|
|
457
|
+
end
|
|
458
|
+
file_path = File.expand_path(path)
|
|
459
|
+
|
|
460
|
+
# If imports are enabled and base_path not already set, set it for resolving relative imports
|
|
461
|
+
if @options[:import].is_a?(Hash) && @options[:import][:base_path].nil?
|
|
462
|
+
file_dir = File.dirname(file_path)
|
|
463
|
+
@options[:import] = @options[:import].merge(base_path: file_dir)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
css_content = File.read(file_path)
|
|
467
|
+
else
|
|
468
|
+
raise ArgumentError, "Unsupported URI scheme: #{uri_obj.scheme}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
add_block(css_content)
|
|
472
|
+
self
|
|
473
|
+
rescue Errno::ENOENT
|
|
474
|
+
raise IOError, "File not found: #{uri}" if @options[:io_exceptions]
|
|
475
|
+
|
|
476
|
+
self
|
|
477
|
+
rescue StandardError => e
|
|
478
|
+
raise IOError, "Error loading URI: #{uri} - #{e.message}" if @options[:io_exceptions]
|
|
479
|
+
|
|
480
|
+
self
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Remove rules matching criteria
|
|
484
|
+
#
|
|
485
|
+
# @param selector [String, nil] Selector to match (nil matches all)
|
|
486
|
+
# @param media_types [Symbol, Array<Symbol>, nil] Media types to filter by (nil matches all)
|
|
487
|
+
# @return [self] Returns self for method chaining
|
|
488
|
+
#
|
|
489
|
+
# @example Remove all rules with a specific selector
|
|
490
|
+
# sheet.remove_rules!(selector: '.header')
|
|
491
|
+
#
|
|
492
|
+
# @example Remove rules from specific media type
|
|
493
|
+
# sheet.remove_rules!(selector: '.header', media_types: :screen)
|
|
494
|
+
#
|
|
495
|
+
# @example Remove all rules from a media type
|
|
496
|
+
# sheet.remove_rules!(media_types: :print)
|
|
497
|
+
def remove_rules!(selector: nil, media_types: nil)
|
|
498
|
+
# Normalize media_types to array
|
|
499
|
+
filter_media = media_types ? Array(media_types).map(&:to_sym) : nil
|
|
500
|
+
|
|
501
|
+
# Find rules to remove
|
|
502
|
+
rules_to_remove = Set.new
|
|
503
|
+
@rules.each_with_index do |rule, rule_id|
|
|
504
|
+
# Check selector match
|
|
505
|
+
next if selector && rule.selector != selector
|
|
506
|
+
|
|
507
|
+
# Check media type match
|
|
508
|
+
if filter_media
|
|
509
|
+
rule_media_types = @_media_index.select { |_media, ids| ids.include?(rule_id) }.keys
|
|
510
|
+
# Extract individual media types from complex queries
|
|
511
|
+
individual_types = rule_media_types.flat_map { |key| Cataract.parse_media_types(key) }.uniq
|
|
512
|
+
|
|
513
|
+
# If rule is not in any media query (base rule), skip if filtering by media
|
|
514
|
+
if individual_types.empty?
|
|
515
|
+
next unless filter_media.include?(:all)
|
|
516
|
+
else
|
|
517
|
+
# Check if rule's media types intersect with filter
|
|
518
|
+
next unless individual_types.intersect?(filter_media)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
rules_to_remove << rule_id
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Remove rules and update media_index
|
|
526
|
+
rules_to_remove.sort.reverse_each do |rule_id|
|
|
527
|
+
@rules.delete_at(rule_id)
|
|
528
|
+
|
|
529
|
+
# Remove from media_index and update IDs for rules after this one
|
|
530
|
+
@_media_index.each_value do |ids|
|
|
531
|
+
ids.delete(rule_id)
|
|
532
|
+
# Decrement IDs greater than removed ID
|
|
533
|
+
ids.map! { |id| id > rule_id ? id - 1 : id }
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Clean up empty media_index entries
|
|
538
|
+
@_media_index.delete_if { |_media, ids| ids.empty? }
|
|
539
|
+
|
|
540
|
+
# Update rule IDs in remaining rules
|
|
541
|
+
@rules.each_with_index { |rule, new_id| rule.id = new_id }
|
|
542
|
+
|
|
543
|
+
# Clear memoized cache
|
|
544
|
+
@selectors = nil
|
|
545
|
+
|
|
546
|
+
self
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Add CSS block to stylesheet
|
|
550
|
+
#
|
|
551
|
+
# @param css [String] CSS string to add
|
|
552
|
+
# @param fix_braces [Boolean] Automatically close missing braces
|
|
553
|
+
# @param media_types [Symbol, Array<Symbol>] Optional media query to wrap CSS in
|
|
554
|
+
# @return [self] Returns self for method chaining
|
|
555
|
+
# TODO: Move to C?
|
|
556
|
+
def add_block(css, fix_braces: false, media_types: nil)
|
|
557
|
+
css += ' }' if fix_braces && !css.strip.end_with?('}')
|
|
558
|
+
|
|
559
|
+
# Convenience wrapper: wrap in @media if media_types specified
|
|
560
|
+
if media_types
|
|
561
|
+
media_list = Array(media_types).join(', ')
|
|
562
|
+
css = "@media #{media_list} { #{css} }"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Resolve @import statements if configured in constructor
|
|
566
|
+
css_to_parse = if @options[:import]
|
|
567
|
+
ImportResolver.resolve(css, @options[:import])
|
|
568
|
+
else
|
|
569
|
+
css
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Get current rule ID offset
|
|
573
|
+
offset = @_last_rule_id || 0
|
|
574
|
+
|
|
575
|
+
# Parse CSS with C function (returns hash)
|
|
576
|
+
result = Cataract._parse_css(css_to_parse)
|
|
577
|
+
|
|
578
|
+
# Merge rules with offsetted IDs
|
|
579
|
+
new_rules = result[:rules]
|
|
580
|
+
new_rules.each do |rule|
|
|
581
|
+
rule.id += offset
|
|
582
|
+
@rules << rule
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Merge media_index with offsetted IDs
|
|
586
|
+
result[:_media_index].each do |media_sym, rule_ids|
|
|
587
|
+
offsetted_ids = rule_ids.map { |id| id + offset }
|
|
588
|
+
if @_media_index[media_sym]
|
|
589
|
+
@_media_index[media_sym].concat(offsetted_ids)
|
|
590
|
+
else
|
|
591
|
+
@_media_index[media_sym] = offsetted_ids
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Update last rule ID
|
|
596
|
+
@_last_rule_id = offset + new_rules.length
|
|
597
|
+
|
|
598
|
+
# Set charset if not already set
|
|
599
|
+
@charset ||= result[:charset]
|
|
600
|
+
|
|
601
|
+
# Track if we have any nesting (for serialization optimization)
|
|
602
|
+
@_has_nesting = result[:_has_nesting]
|
|
603
|
+
|
|
604
|
+
self
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Add a single rule
|
|
608
|
+
#
|
|
609
|
+
# @param selector [String] CSS selector
|
|
610
|
+
# @param declarations [String] CSS declarations (property: value pairs)
|
|
611
|
+
# @param media_types [Symbol, Array<Symbol>] Optional media types to wrap rule in
|
|
612
|
+
# @return [self] Returns self for method chaining
|
|
613
|
+
def add_rule(selector:, declarations:, media_types: nil)
|
|
614
|
+
# Wrap in CSS syntax and add as block
|
|
615
|
+
css = "#{selector} { #{declarations} }"
|
|
616
|
+
add_block(css, media_types: media_types)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Convert to hash
|
|
620
|
+
#
|
|
621
|
+
# @return [Hash] Hash representation
|
|
622
|
+
def to_h
|
|
623
|
+
{
|
|
624
|
+
rules: @rules,
|
|
625
|
+
charset: @charset
|
|
626
|
+
}
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def inspect
|
|
630
|
+
total_rules = size
|
|
631
|
+
if total_rules.zero?
|
|
632
|
+
'#<Cataract::Stylesheet empty>'
|
|
633
|
+
else
|
|
634
|
+
preview = @rules.first(3).map(&:selector).join(', ')
|
|
635
|
+
more = total_rules > 3 ? ', ...' : ''
|
|
636
|
+
"#<Cataract::Stylesheet #{total_rules} rules: #{preview}#{more}>"
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Merge all rules in this stylesheet according to CSS cascade rules
|
|
641
|
+
#
|
|
642
|
+
# Applies specificity and !important precedence rules to compute the final
|
|
643
|
+
# set of declarations. Also recreates shorthand properties from longhand
|
|
644
|
+
# properties where possible.
|
|
645
|
+
#
|
|
646
|
+
# @return [Stylesheet] New stylesheet with a single merged rule
|
|
647
|
+
def merge
|
|
648
|
+
# C function handles everything - returns new Stylesheet
|
|
649
|
+
Cataract.merge(self)
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Merge rules in-place, mutating the receiver.
|
|
653
|
+
#
|
|
654
|
+
# This is a convenience method that updates the stylesheet's internal
|
|
655
|
+
# rules and media_index with the merged result. The Stylesheet object
|
|
656
|
+
# itself is mutated (same object_id), but note that the C merge function
|
|
657
|
+
# still allocates new arrays internally.
|
|
658
|
+
#
|
|
659
|
+
# @return [self] Returns self for method chaining
|
|
660
|
+
def merge!
|
|
661
|
+
merged = Cataract.merge(self)
|
|
662
|
+
@rules = merged.instance_variable_get(:@rules)
|
|
663
|
+
@_media_index = merged.instance_variable_get(:@_media_index)
|
|
664
|
+
@_has_nesting = merged.instance_variable_get(:@_has_nesting)
|
|
665
|
+
self
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
private
|
|
669
|
+
|
|
670
|
+
# @private
|
|
671
|
+
# Internal index mapping media query symbols to rule IDs for efficient filtering.
|
|
672
|
+
# This is an implementation detail and should not be relied upon by external code.
|
|
673
|
+
# @return [Hash<Symbol, Array<Integer>>]
|
|
674
|
+
attr_reader :_media_index
|
|
675
|
+
|
|
676
|
+
# Check if a rule matches any of the requested media queries
|
|
677
|
+
#
|
|
678
|
+
# @param rule_id [Integer] Rule ID to check
|
|
679
|
+
# @param query_media [Array<Symbol>] Media types to match
|
|
680
|
+
# @return [Boolean] true if rule appears in any of the requested media index entries
|
|
681
|
+
def rule_matches_media?(rule_id, query_media)
|
|
682
|
+
query_media.any? { |m| @_media_index[m]&.include?(rule_id) }
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Check if a rule matches the specificity filter
|
|
686
|
+
#
|
|
687
|
+
# @param rule [Rule] Rule to check
|
|
688
|
+
# @param specificity [Integer, Range] Specificity filter
|
|
689
|
+
# @return [Boolean] true if rule matches specificity
|
|
690
|
+
def rule_matches_specificity?(rule, specificity)
|
|
691
|
+
# Skip rules with nil specificity (e.g., AtRule)
|
|
692
|
+
return false if rule.specificity.nil?
|
|
693
|
+
|
|
694
|
+
case specificity
|
|
695
|
+
when Range
|
|
696
|
+
specificity.cover?(rule.specificity)
|
|
697
|
+
else
|
|
698
|
+
specificity == rule.specificity
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Check if a rule has a declaration matching property and/or value
|
|
703
|
+
#
|
|
704
|
+
# @param rule [Rule] Rule to check (AtRule filtered out by each_selector)
|
|
705
|
+
# @param property [String, nil] Property name to match
|
|
706
|
+
# @param property_value [String, nil] Property value to match
|
|
707
|
+
# @return [Boolean] true if rule has matching declaration
|
|
708
|
+
def rule_matches_property?(rule, property, property_value)
|
|
709
|
+
rule.declarations.any? do |decl|
|
|
710
|
+
property_matches = property.nil? || decl.property == property
|
|
711
|
+
value_matches = property_value.nil? || decl.value == property_value
|
|
712
|
+
property_matches && value_matches
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
end
|
|
716
|
+
end
|