cataract 0.2.5 → 0.4.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 +4 -4
- data/.github/workflows/test.yml +6 -6
- data/.gitignore +17 -6
- data/.rubocop_todo.yml +11 -19
- data/BENCHMARKS.md +40 -40
- data/CHANGELOG.md +28 -1
- data/Gemfile +2 -2
- data/examples/css_analyzer/analyzer.rb +9 -3
- data/examples/css_analyzer/analyzers/base.rb +1 -1
- data/ext/cataract/cataract.c +312 -550
- data/ext/cataract/cataract.h +126 -16
- data/ext/cataract/css_parser.c +782 -806
- data/ext/cataract/extconf.rb +1 -2
- data/ext/cataract/flatten.c +279 -991
- data/ext/cataract/shorthand_expander.c +80 -102
- data/ext/cataract_color/color_conversion.c +1 -1
- data/lib/cataract/declarations.rb +26 -10
- data/lib/cataract/import_resolver.rb +36 -16
- data/lib/cataract/native.rb +30 -0
- data/lib/cataract/pure/byte_constants.rb +70 -67
- data/lib/cataract/pure/declarations.rb +125 -0
- data/lib/cataract/pure/flatten.rb +1195 -1182
- data/lib/cataract/pure/parser.rb +1721 -1652
- data/lib/cataract/pure/serializer.rb +575 -710
- data/lib/cataract/pure/specificity.rb +152 -175
- data/lib/cataract/pure.rb +73 -108
- data/lib/cataract/rule.rb +6 -3
- data/lib/cataract/stylesheet.rb +475 -415
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +41 -30
- data/lib/tasks/profile.rake +6 -3
- metadata +4 -14
- data/ext/cataract/import_scanner.c +0 -174
- data/ext/cataract_old/cataract.c +0 -393
- data/ext/cataract_old/cataract.h +0 -250
- data/ext/cataract_old/css_parser.c +0 -933
- data/ext/cataract_old/extconf.rb +0 -67
- data/ext/cataract_old/import_scanner.c +0 -174
- data/ext/cataract_old/merge.c +0 -776
- data/ext/cataract_old/shorthand_expander.c +0 -902
- data/ext/cataract_old/specificity.c +0 -213
- data/ext/cataract_old/stylesheet.c +0 -290
- data/ext/cataract_old/value_splitter.c +0 -116
- data/lib/cataract/pure/helpers.rb +0 -35
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -15,1935 +15,2004 @@
|
|
|
15
15
|
# matter in a hot parsing loop.
|
|
16
16
|
|
|
17
17
|
module Cataract
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
i += 1
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
true
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
|
|
67
|
-
# Type validation
|
|
68
|
-
raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
|
|
69
|
-
|
|
70
|
-
# Private: Internal parsing state
|
|
71
|
-
@_css = css_string.dup.freeze
|
|
72
|
-
@_pos = 0
|
|
73
|
-
@_len = @_css.bytesize
|
|
74
|
-
@_parent_media_sym = parent_media_sym
|
|
75
|
-
@_parent_media_query_id = parent_media_query_id
|
|
76
|
-
@_depth = depth # Current recursion depth (passed from parent parser)
|
|
77
|
-
|
|
78
|
-
# Private: Parser options with defaults
|
|
79
|
-
@_parser_options = {
|
|
80
|
-
selector_lists: true,
|
|
81
|
-
base_uri: nil,
|
|
82
|
-
absolute_paths: false,
|
|
83
|
-
uri_resolver: nil,
|
|
84
|
-
raise_parse_errors: false
|
|
85
|
-
}.merge(parser_options)
|
|
86
|
-
|
|
87
|
-
# Private: Extract options to ivars to avoid repeated hash lookups in hot path
|
|
88
|
-
@_selector_lists_enabled = @_parser_options[:selector_lists]
|
|
89
|
-
@_base_uri = @_parser_options[:base_uri]
|
|
90
|
-
@_absolute_paths = @_parser_options[:absolute_paths]
|
|
91
|
-
@_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
92
|
-
|
|
93
|
-
# Parse error handling options - extract to ivars for hot path performance
|
|
94
|
-
@_raise_parse_errors = @_parser_options[:raise_parse_errors]
|
|
95
|
-
if @_raise_parse_errors.is_a?(Hash)
|
|
96
|
-
# Granular control - default all to false (opt-in)
|
|
97
|
-
@_check_empty_values = @_raise_parse_errors[:empty_values] || false
|
|
98
|
-
@_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
|
|
99
|
-
@_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
|
|
100
|
-
@_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
|
|
101
|
-
@_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
|
|
102
|
-
@_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
|
|
103
|
-
elsif @_raise_parse_errors == true
|
|
104
|
-
# Enable all error checks
|
|
105
|
-
@_check_empty_values = true
|
|
106
|
-
@_check_malformed_declarations = true
|
|
107
|
-
@_check_invalid_selectors = true
|
|
108
|
-
@_check_invalid_selector_syntax = true
|
|
109
|
-
@_check_malformed_at_rules = true
|
|
110
|
-
@_check_unclosed_blocks = true
|
|
111
|
-
else
|
|
112
|
-
# Disabled
|
|
113
|
-
@_check_empty_values = false
|
|
114
|
-
@_check_malformed_declarations = false
|
|
115
|
-
@_check_invalid_selectors = false
|
|
116
|
-
@_check_invalid_selector_syntax = false
|
|
117
|
-
@_check_malformed_at_rules = false
|
|
118
|
-
@_check_unclosed_blocks = false
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Private: Internal counters
|
|
122
|
-
@_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
|
|
123
|
-
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
124
|
-
@_next_media_query_list_id = 0 # Counter for media query list IDs
|
|
125
|
-
@_rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
126
|
-
@_media_query_count = 0 # Safety limit
|
|
127
|
-
|
|
128
|
-
# Public: Parser results (returned in parse result hash)
|
|
129
|
-
@rules = [] # Flat array of Rule structs
|
|
130
|
-
@media_queries = [] # Array of MediaQuery objects
|
|
131
|
-
@media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
|
|
132
|
-
@imports = [] # Array of ImportStatement structs
|
|
133
|
-
@charset = nil # @charset declaration
|
|
134
|
-
|
|
135
|
-
# Semi-private: Internal state exposed with _ prefix in result
|
|
136
|
-
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
137
|
-
@_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
|
|
138
|
-
@_has_nesting = false # Set to true if any nested rules found
|
|
139
|
-
end
|
|
18
|
+
module Backends
|
|
19
|
+
class PureImpl
|
|
20
|
+
# Pure Ruby CSS parser - char-by-char, NO REGEXP
|
|
21
|
+
class Parser
|
|
22
|
+
# Maximum parse depth (prevent infinite recursion)
|
|
23
|
+
MAX_PARSE_DEPTH = 10
|
|
24
|
+
|
|
25
|
+
# Maximum media queries (prevent symbol table exhaustion)
|
|
26
|
+
MAX_MEDIA_QUERIES = 1000
|
|
27
|
+
|
|
28
|
+
AT_RULE_TYPES = %w[supports layer container scope].freeze
|
|
29
|
+
|
|
30
|
+
# Extract substring and force specified encoding
|
|
31
|
+
# Per CSS spec, charset detection happens at byte-stream level before parsing.
|
|
32
|
+
# All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
|
|
33
|
+
# This prevents ArgumentError on broken/invalid encodings when calling string methods.
|
|
34
|
+
# Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
|
|
35
|
+
def byteslice_encoded(start, length, encoding: 'UTF-8')
|
|
36
|
+
@_css.byteslice(start, length).force_encoding(encoding)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Helper: Case-insensitive ASCII byte comparison
|
|
40
|
+
# Compares bytes at given position with ASCII pattern (case-insensitive)
|
|
41
|
+
# Safe to use even if position is in middle of multi-byte UTF-8 characters
|
|
42
|
+
# Returns true if match, false otherwise
|
|
43
|
+
def match_ascii_ci?(str, pos, pattern)
|
|
44
|
+
pattern_len = pattern.bytesize
|
|
45
|
+
return false if pos + pattern_len > str.bytesize
|
|
46
|
+
|
|
47
|
+
i = 0
|
|
48
|
+
while i < pattern_len
|
|
49
|
+
str_byte = str.getbyte(pos + i)
|
|
50
|
+
pat_byte = pattern.getbyte(i)
|
|
51
|
+
|
|
52
|
+
# Convert both to lowercase for comparison (ASCII only: A-Z -> a-z)
|
|
53
|
+
str_byte += BYTE_CASE_DIFF if str_byte >= BYTE_UPPER_A && str_byte <= BYTE_UPPER_Z
|
|
54
|
+
pat_byte += BYTE_CASE_DIFF if pat_byte >= BYTE_UPPER_A && pat_byte <= BYTE_UPPER_Z
|
|
55
|
+
|
|
56
|
+
return false if str_byte != pat_byte
|
|
57
|
+
|
|
58
|
+
i += 1
|
|
59
|
+
end
|
|
140
60
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
|
|
65
|
+
# Type validation
|
|
66
|
+
raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
|
|
67
|
+
|
|
68
|
+
# Private: Internal parsing state
|
|
69
|
+
@_css = css_string.dup.freeze
|
|
70
|
+
@_pos = 0
|
|
71
|
+
@_len = @_css.bytesize
|
|
72
|
+
@_parent_media_sym = parent_media_sym
|
|
73
|
+
@_parent_media_query_id = parent_media_query_id
|
|
74
|
+
@_depth = depth # Current recursion depth (passed from parent parser)
|
|
75
|
+
|
|
76
|
+
# Private: Parser options with defaults
|
|
77
|
+
@_parser_options = {
|
|
78
|
+
selector_lists: true,
|
|
79
|
+
base_uri: nil,
|
|
80
|
+
absolute_paths: false,
|
|
81
|
+
uri_resolver: nil,
|
|
82
|
+
raise_parse_errors: false
|
|
83
|
+
}.merge(parser_options)
|
|
84
|
+
|
|
85
|
+
# Private: Extract options to ivars to avoid repeated hash lookups in hot path
|
|
86
|
+
@_selector_lists_enabled = @_parser_options[:selector_lists]
|
|
87
|
+
@_base_uri = @_parser_options[:base_uri]
|
|
88
|
+
@_absolute_paths = @_parser_options[:absolute_paths]
|
|
89
|
+
@_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
90
|
+
|
|
91
|
+
# Parse error handling options - extract to ivars for hot path performance
|
|
92
|
+
@_raise_parse_errors = @_parser_options[:raise_parse_errors]
|
|
93
|
+
if @_raise_parse_errors.is_a?(Hash)
|
|
94
|
+
# Granular control - default all to false (opt-in)
|
|
95
|
+
@_check_empty_values = @_raise_parse_errors[:empty_values] || false
|
|
96
|
+
@_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
|
|
97
|
+
@_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
|
|
98
|
+
@_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
|
|
99
|
+
@_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
|
|
100
|
+
@_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
|
|
101
|
+
elsif @_raise_parse_errors == true
|
|
102
|
+
# Enable all error checks
|
|
103
|
+
@_check_empty_values = true
|
|
104
|
+
@_check_malformed_declarations = true
|
|
105
|
+
@_check_invalid_selectors = true
|
|
106
|
+
@_check_invalid_selector_syntax = true
|
|
107
|
+
@_check_malformed_at_rules = true
|
|
108
|
+
@_check_unclosed_blocks = true
|
|
109
|
+
else
|
|
110
|
+
# Disabled
|
|
111
|
+
@_check_empty_values = false
|
|
112
|
+
@_check_malformed_declarations = false
|
|
113
|
+
@_check_invalid_selectors = false
|
|
114
|
+
@_check_invalid_selector_syntax = false
|
|
115
|
+
@_check_malformed_at_rules = false
|
|
116
|
+
@_check_unclosed_blocks = false
|
|
117
|
+
end
|
|
144
118
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
119
|
+
# Private: Internal counters
|
|
120
|
+
@_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
|
|
121
|
+
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
122
|
+
@_next_media_query_list_id = 0 # Counter for media query list IDs
|
|
123
|
+
@_rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
124
|
+
@_media_query_count = 0 # Safety limit
|
|
125
|
+
|
|
126
|
+
# Public: Parser results (returned in parse result hash)
|
|
127
|
+
@rules = [] # Flat array of Rule structs
|
|
128
|
+
@media_queries = [] # Array of MediaQuery objects
|
|
129
|
+
@media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
|
|
130
|
+
@imports = [] # Array of ImportStatement structs
|
|
131
|
+
@charset = nil # @charset declaration
|
|
132
|
+
|
|
133
|
+
# Semi-private: Internal state exposed with _ prefix in result
|
|
134
|
+
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
135
|
+
@_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
|
|
136
|
+
@_has_nesting = false # Set to true if any nested rules found
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def parse
|
|
140
|
+
# @import statements are now handled in parse_at_rule
|
|
141
|
+
# They must come before all rules (except @charset) per CSS spec
|
|
142
|
+
|
|
143
|
+
# Main parsing loop - char-by-char, NO REGEXP
|
|
144
|
+
until eof?
|
|
145
|
+
skip_ws_and_comments
|
|
146
|
+
break if eof?
|
|
147
|
+
|
|
148
|
+
# Peek at next byte to determine what to parse
|
|
149
|
+
byte = peek_byte
|
|
150
|
+
|
|
151
|
+
# Check for at-rules (@media, @charset, etc)
|
|
152
|
+
if byte == BYTE_AT
|
|
153
|
+
parse_at_rule
|
|
154
|
+
next
|
|
155
|
+
end
|
|
149
156
|
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
# Must be a selector-based rule
|
|
158
|
+
selector = parse_selector
|
|
152
159
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
next
|
|
157
|
-
end
|
|
160
|
+
if selector.nil? || selector.empty?
|
|
161
|
+
next
|
|
162
|
+
end
|
|
158
163
|
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
# Find the block boundaries
|
|
165
|
+
decl_start = @_pos # Should be right after the {
|
|
166
|
+
decl_end = find_matching_brace(decl_start)
|
|
167
|
+
|
|
168
|
+
# Check if block has nested selectors
|
|
169
|
+
if has_nested_selectors?(decl_start, decl_end)
|
|
170
|
+
# NESTED PATH: Parse mixed declarations + nested rules
|
|
171
|
+
# Split comma-separated selectors and parse each one
|
|
172
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
173
|
+
|
|
174
|
+
selectors.each do |individual_selector|
|
|
175
|
+
next if individual_selector.empty?
|
|
176
|
+
|
|
177
|
+
# Get rule ID for this selector
|
|
178
|
+
current_rule_id = @_rule_id_counter
|
|
179
|
+
@_rule_id_counter += 1
|
|
180
|
+
|
|
181
|
+
# Reserve parent's position in rules array (ensures parent comes before nested)
|
|
182
|
+
parent_position = @rules.length
|
|
183
|
+
@rules << nil # Placeholder
|
|
184
|
+
|
|
185
|
+
# Parse mixed block (declarations + nested selectors)
|
|
186
|
+
@_depth += 1
|
|
187
|
+
parent_declarations = parse_mixed_block(decl_start, decl_end,
|
|
188
|
+
individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
|
|
189
|
+
@_depth -= 1
|
|
190
|
+
|
|
191
|
+
# Create parent rule and replace placeholder
|
|
192
|
+
rule = Rule.new(
|
|
193
|
+
current_rule_id,
|
|
194
|
+
individual_selector,
|
|
195
|
+
parent_declarations,
|
|
196
|
+
nil, # specificity
|
|
197
|
+
nil, # parent_rule_id (top-level)
|
|
198
|
+
nil # nesting_style
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@rules[parent_position] = rule
|
|
202
|
+
end
|
|
161
203
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
204
|
+
# Move position past the closing brace
|
|
205
|
+
@_pos = decl_end
|
|
206
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
207
|
+
else
|
|
208
|
+
# NON-NESTED PATH: Parse declarations only
|
|
209
|
+
@_pos = decl_start # Reset to start of block
|
|
210
|
+
declarations = parse_declarations
|
|
211
|
+
|
|
212
|
+
# Split comma-separated selectors into individual rules
|
|
213
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
214
|
+
|
|
215
|
+
# Determine if we should track this as a selector list
|
|
216
|
+
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
217
|
+
list_id = nil
|
|
218
|
+
if @_selector_lists_enabled && selectors.size > 1
|
|
219
|
+
list_id = @_next_selector_list_id
|
|
220
|
+
@_next_selector_list_id += 1
|
|
221
|
+
@_selector_lists[list_id] = []
|
|
222
|
+
end
|
|
165
223
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
decl_end = find_matching_brace(decl_start)
|
|
224
|
+
selectors.each do |individual_selector|
|
|
225
|
+
next if individual_selector.empty?
|
|
169
226
|
|
|
170
|
-
|
|
171
|
-
if has_nested_selectors?(decl_start, decl_end)
|
|
172
|
-
# NESTED PATH: Parse mixed declarations + nested rules
|
|
173
|
-
# Split comma-separated selectors and parse each one
|
|
174
|
-
selectors = selector.split(',')
|
|
227
|
+
rule_id = @_rule_id_counter
|
|
175
228
|
|
|
176
|
-
|
|
177
|
-
|
|
229
|
+
# Dup declarations for each rule in a selector list to avoid shared state
|
|
230
|
+
# (principle of least surprise - modifying one rule shouldn't affect others)
|
|
231
|
+
# Must deep dup: both the array and the Declaration objects inside
|
|
232
|
+
rule_declarations = if list_id
|
|
233
|
+
declarations.map { |d| Declaration.new(d.property, d.value, d.important) }
|
|
234
|
+
else
|
|
235
|
+
declarations
|
|
236
|
+
end
|
|
178
237
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
238
|
+
# Create Rule struct (with selector_list_id as 7th parameter)
|
|
239
|
+
rule = Rule.new(
|
|
240
|
+
rule_id, # id
|
|
241
|
+
individual_selector, # selector
|
|
242
|
+
rule_declarations, # declarations
|
|
243
|
+
nil, # specificity (calculated lazily)
|
|
244
|
+
nil, # parent_rule_id
|
|
245
|
+
nil, # nesting_style
|
|
246
|
+
list_id # selector_list_id
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
@rules << rule
|
|
250
|
+
@_rule_id_counter += 1
|
|
251
|
+
|
|
252
|
+
# Track in selector list if applicable
|
|
253
|
+
@_selector_lists[list_id] << rule_id if list_id
|
|
254
|
+
end
|
|
183
255
|
end
|
|
184
|
-
|
|
185
|
-
next if individual_selector.empty?
|
|
186
|
-
|
|
187
|
-
# Get rule ID for this selector
|
|
188
|
-
current_rule_id = @_rule_id_counter
|
|
189
|
-
@_rule_id_counter += 1
|
|
190
|
-
|
|
191
|
-
# Reserve parent's position in rules array (ensures parent comes before nested)
|
|
192
|
-
parent_position = @rules.length
|
|
193
|
-
@rules << nil # Placeholder
|
|
194
|
-
|
|
195
|
-
# Parse mixed block (declarations + nested selectors)
|
|
196
|
-
@_depth += 1
|
|
197
|
-
parent_declarations = parse_mixed_block(decl_start, decl_end,
|
|
198
|
-
individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
|
|
199
|
-
@_depth -= 1
|
|
200
|
-
|
|
201
|
-
# Create parent rule and replace placeholder
|
|
202
|
-
rule = Rule.new(
|
|
203
|
-
current_rule_id,
|
|
204
|
-
individual_selector,
|
|
205
|
-
parent_declarations,
|
|
206
|
-
nil, # specificity
|
|
207
|
-
nil, # parent_rule_id (top-level)
|
|
208
|
-
nil # nesting_style
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
@rules[parent_position] = rule
|
|
212
256
|
end
|
|
213
257
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
258
|
+
{
|
|
259
|
+
rules: @rules,
|
|
260
|
+
_media_index: @media_index,
|
|
261
|
+
media_queries: @media_queries,
|
|
262
|
+
_selector_lists: @_selector_lists,
|
|
263
|
+
_media_query_lists: @_media_query_lists,
|
|
264
|
+
imports: @imports,
|
|
265
|
+
charset: @charset,
|
|
266
|
+
_has_nesting: @_has_nesting
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
# Split a comma-separated selector list into individual selector strings,
|
|
273
|
+
# stripping whitespace from each and validating (in strict mode) that
|
|
274
|
+
# none are empty. Empty entries are left in the returned array - callers
|
|
275
|
+
# skip them - since the emptiness check needs the original
|
|
276
|
+
# comma-separated count (selectors.size > 1) to know whether this is
|
|
277
|
+
# really a list or just a single selector, which callers no longer have
|
|
278
|
+
# once they've filtered.
|
|
279
|
+
#
|
|
280
|
+
# @param selector [String] Raw selector text (already trimmed as a whole)
|
|
281
|
+
# @param pos [Integer] Position to report in a raised ParseError
|
|
282
|
+
# @return [Array<String>] Individual selectors, stripped
|
|
283
|
+
def split_and_validate_selectors(selector, pos)
|
|
223
284
|
selectors = selector.split(',')
|
|
224
|
-
|
|
225
|
-
# Determine if we should track this as a selector list
|
|
226
|
-
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
227
|
-
list_id = nil
|
|
228
|
-
if @_selector_lists_enabled && selectors.size > 1
|
|
229
|
-
list_id = @_next_selector_list_id
|
|
230
|
-
@_next_selector_list_id += 1
|
|
231
|
-
@_selector_lists[list_id] = []
|
|
232
|
-
end
|
|
233
|
-
|
|
234
285
|
selectors.each do |individual_selector|
|
|
235
286
|
individual_selector.strip!
|
|
236
287
|
|
|
237
|
-
# Check for empty selector in comma-separated list
|
|
238
288
|
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
239
289
|
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
240
|
-
css: @_css, pos:
|
|
290
|
+
css: @_css, pos: pos, type: :invalid_selector_syntax)
|
|
241
291
|
end
|
|
242
|
-
|
|
243
|
-
next if individual_selector.empty?
|
|
244
|
-
|
|
245
|
-
rule_id = @_rule_id_counter
|
|
246
|
-
|
|
247
|
-
# Dup declarations for each rule in a selector list to avoid shared state
|
|
248
|
-
# (principle of least surprise - modifying one rule shouldn't affect others)
|
|
249
|
-
# Must deep dup: both the array and the Declaration objects inside
|
|
250
|
-
rule_declarations = if list_id
|
|
251
|
-
declarations.map { |d| Declaration.new(d.property, d.value, d.important) }
|
|
252
|
-
else
|
|
253
|
-
declarations
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
# Create Rule struct (with selector_list_id as 7th parameter)
|
|
257
|
-
rule = Rule.new(
|
|
258
|
-
rule_id, # id
|
|
259
|
-
individual_selector, # selector
|
|
260
|
-
rule_declarations, # declarations
|
|
261
|
-
nil, # specificity (calculated lazily)
|
|
262
|
-
nil, # parent_rule_id
|
|
263
|
-
nil, # nesting_style
|
|
264
|
-
list_id # selector_list_id
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
@rules << rule
|
|
268
|
-
@_rule_id_counter += 1
|
|
269
|
-
|
|
270
|
-
# Track in selector list if applicable
|
|
271
|
-
@_selector_lists[list_id] << rule_id if list_id
|
|
272
292
|
end
|
|
293
|
+
selectors
|
|
273
294
|
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
{
|
|
277
|
-
rules: @rules,
|
|
278
|
-
_media_index: @media_index,
|
|
279
|
-
media_queries: @media_queries,
|
|
280
|
-
_selector_lists: @_selector_lists,
|
|
281
|
-
_media_query_lists: @_media_query_lists,
|
|
282
|
-
imports: @imports,
|
|
283
|
-
charset: @charset,
|
|
284
|
-
_has_nesting: @_has_nesting
|
|
285
|
-
}
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
private
|
|
289
|
-
|
|
290
|
-
# Check if we're at end of input
|
|
291
|
-
def eof?
|
|
292
|
-
@_pos >= @_len
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Peek current byte without advancing
|
|
296
|
-
# @return [Integer, nil] Byte value or nil if EOF
|
|
297
|
-
def peek_byte
|
|
298
|
-
return nil if eof?
|
|
299
|
-
|
|
300
|
-
@_css.getbyte(@_pos)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Delegate to module-level helper methods (now work with bytes)
|
|
304
|
-
def whitespace?(byte)
|
|
305
|
-
Cataract.is_whitespace?(byte)
|
|
306
|
-
end
|
|
307
295
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def digit?(byte)
|
|
313
|
-
Cataract.digit?(byte)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def ident_char?(byte)
|
|
317
|
-
Cataract.ident_char?(byte)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def skip_whitespace
|
|
321
|
-
@_pos += 1 while !eof? && whitespace?(peek_byte)
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def skip_comment # rubocop:disable Naming/PredicateMethod
|
|
325
|
-
return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
|
|
326
|
-
|
|
327
|
-
@_pos += 2 # Skip /*
|
|
328
|
-
while @_pos + 1 < @_len
|
|
329
|
-
if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
|
|
330
|
-
@_pos += 2 # Skip */
|
|
331
|
-
return true
|
|
296
|
+
# Check if we're at end of input
|
|
297
|
+
def eof?
|
|
298
|
+
@_pos >= @_len
|
|
332
299
|
end
|
|
333
|
-
@_pos += 1
|
|
334
|
-
end
|
|
335
|
-
true
|
|
336
|
-
end
|
|
337
300
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
# - begin...end until: 24 instructions, simple jump-based loop, no catch table
|
|
343
|
-
# Benchmark shows 15-51% speedup depending on YJIT
|
|
344
|
-
def skip_ws_and_comments
|
|
345
|
-
begin
|
|
346
|
-
old_pos = @_pos
|
|
347
|
-
skip_whitespace
|
|
348
|
-
skip_comment
|
|
349
|
-
end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
|
|
350
|
-
end
|
|
301
|
+
# Peek current byte without advancing
|
|
302
|
+
# @return [Integer, nil] Byte value or nil if EOF
|
|
303
|
+
def peek_byte
|
|
304
|
+
return nil if eof?
|
|
351
305
|
|
|
352
|
-
|
|
353
|
-
# Returns true if valid, false if invalid
|
|
354
|
-
# Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
|
|
355
|
-
def valid_selector_syntax?(selector_text)
|
|
356
|
-
i = 0
|
|
357
|
-
len = selector_text.bytesize
|
|
358
|
-
|
|
359
|
-
while i < len
|
|
360
|
-
byte = selector_text.getbyte(i)
|
|
361
|
-
|
|
362
|
-
# Check for invalid character sequences
|
|
363
|
-
if i + 1 < len
|
|
364
|
-
next_byte = selector_text.getbyte(i + 1)
|
|
365
|
-
# Double dot (..) is invalid
|
|
366
|
-
return false if byte == BYTE_DOT && next_byte == BYTE_DOT
|
|
367
|
-
# Double hash (##) is invalid
|
|
368
|
-
return false if byte == BYTE_HASH && next_byte == BYTE_HASH
|
|
306
|
+
@_css.getbyte(@_pos)
|
|
369
307
|
end
|
|
370
308
|
|
|
371
|
-
#
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
309
|
+
# Check if a byte is whitespace (space, tab, newline, CR)
|
|
310
|
+
# @param byte [Integer] Byte value from String#getbyte
|
|
311
|
+
# @return [Boolean] true if whitespace
|
|
312
|
+
def whitespace?(byte)
|
|
313
|
+
byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
375
314
|
end
|
|
376
315
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
i += 1
|
|
380
|
-
next
|
|
316
|
+
def skip_whitespace
|
|
317
|
+
@_pos += 1 while !eof? && whitespace?(peek_byte)
|
|
381
318
|
end
|
|
382
319
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
|
|
386
|
-
BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
|
|
387
|
-
BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
|
|
388
|
-
BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
|
|
389
|
-
BYTE_COMMA
|
|
390
|
-
i += 1
|
|
391
|
-
else
|
|
392
|
-
# Invalid character found
|
|
393
|
-
return false
|
|
394
|
-
end
|
|
395
|
-
end
|
|
320
|
+
def skip_comment # rubocop:disable Naming/PredicateMethod
|
|
321
|
+
return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
|
|
396
322
|
|
|
397
|
-
|
|
398
|
-
|
|
323
|
+
@_pos += 2 # Skip /*
|
|
324
|
+
while @_pos + 1 < @_len
|
|
325
|
+
if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
|
|
326
|
+
@_pos += 2 # Skip */
|
|
327
|
+
return true
|
|
328
|
+
end
|
|
329
|
+
@_pos += 1
|
|
330
|
+
end
|
|
331
|
+
true
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Skip whitespace and comments until no more progress can be made
|
|
335
|
+
#
|
|
336
|
+
# Optimization: Using `begin...end until` instead of `loop + break` reduces VM overhead:
|
|
337
|
+
# - loop + break: 29 instructions with catch table for break/redo/next, uses throw/send
|
|
338
|
+
# - begin...end until: 24 instructions, simple jump-based loop, no catch table
|
|
339
|
+
# Benchmark shows 15-51% speedup depending on YJIT
|
|
340
|
+
def skip_ws_and_comments
|
|
341
|
+
begin
|
|
342
|
+
old_pos = @_pos
|
|
343
|
+
skip_whitespace
|
|
344
|
+
skip_comment
|
|
345
|
+
end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Check if a selector contains only valid CSS selector characters and sequences
|
|
349
|
+
# Returns true if valid, false if invalid
|
|
350
|
+
# Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
|
|
351
|
+
def valid_selector_syntax?(selector_text)
|
|
352
|
+
i = 0
|
|
353
|
+
len = selector_text.bytesize
|
|
354
|
+
|
|
355
|
+
while i < len
|
|
356
|
+
byte = selector_text.getbyte(i)
|
|
357
|
+
|
|
358
|
+
# Check for invalid character sequences
|
|
359
|
+
if i + 1 < len
|
|
360
|
+
next_byte = selector_text.getbyte(i + 1)
|
|
361
|
+
# Double dot (..) is invalid
|
|
362
|
+
return false if byte == BYTE_DOT && next_byte == BYTE_DOT
|
|
363
|
+
# Double hash (##) is invalid
|
|
364
|
+
return false if byte == BYTE_HASH && next_byte == BYTE_HASH
|
|
365
|
+
end
|
|
399
366
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
# @param pos [Integer] Current position in CSS string
|
|
406
|
-
# @param end_pos [Integer] End position (boundary for parsing)
|
|
407
|
-
# @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
|
|
408
|
-
# @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
|
|
409
|
-
def parse_single_declaration(pos, end_pos, parse_important)
|
|
410
|
-
# Parse property name (scan until ':')
|
|
411
|
-
prop_start = pos
|
|
412
|
-
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
413
|
-
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
414
|
-
pos += 1
|
|
415
|
-
end
|
|
367
|
+
# Alphanumeric
|
|
368
|
+
if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
|
|
369
|
+
i += 1
|
|
370
|
+
next
|
|
371
|
+
end
|
|
416
372
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
end
|
|
423
|
-
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
424
|
-
return [nil, pos]
|
|
425
|
-
end
|
|
373
|
+
# Whitespace
|
|
374
|
+
if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
375
|
+
i += 1
|
|
376
|
+
next
|
|
377
|
+
end
|
|
426
378
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
379
|
+
# Valid CSS selector special characters
|
|
380
|
+
case byte
|
|
381
|
+
when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
|
|
382
|
+
BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
|
|
383
|
+
BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
|
|
384
|
+
BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
|
|
385
|
+
BYTE_COMMA
|
|
386
|
+
i += 1
|
|
387
|
+
else
|
|
388
|
+
# Invalid character found
|
|
389
|
+
return false
|
|
390
|
+
end
|
|
391
|
+
end
|
|
432
392
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
393
|
+
true
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Detect and strip a trailing '!important' marker from an already-extracted,
|
|
397
|
+
# already-right-trimmed declaration value, in place. Shared by
|
|
398
|
+
# parse_single_declaration and parse_declarations so both code paths agree
|
|
399
|
+
# on what counts as important.
|
|
400
|
+
#
|
|
401
|
+
# The CSS2.1 grammar defines the IMPORTANT_SYM lexical token as:
|
|
402
|
+
# "!"({w}|{comment})*{I}{M}{P}{O}{R}{T}{A}{N}{T}
|
|
403
|
+
# (https://www.w3.org/TR/CSS2/grammar.html) - i.e. zero or more whitespace
|
|
404
|
+
# tokens are allowed between '!' and 'important'.
|
|
405
|
+
#
|
|
406
|
+
# Mutates value in place (via slice!) instead of returning a new
|
|
407
|
+
# [value, important] tuple, so the common case (no !important present)
|
|
408
|
+
# allocates nothing beyond the two getbyte/compare checks below.
|
|
409
|
+
#
|
|
410
|
+
# @param value [String] already-extracted, right-trimmed declaration value (mutated in place)
|
|
411
|
+
# @return [Boolean] whether an important marker was found and stripped
|
|
412
|
+
def extract_important!(value) # rubocop:disable Naming/PredicateMethod
|
|
413
|
+
return false if value.bytesize < 10
|
|
441
414
|
|
|
442
|
-
|
|
415
|
+
i = value.bytesize - 1
|
|
416
|
+
# Skip trailing whitespace
|
|
417
|
+
while i >= 0 && whitespace?(value.getbyte(i))
|
|
418
|
+
i -= 1
|
|
419
|
+
end
|
|
443
420
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
pos += 1
|
|
447
|
-
end
|
|
421
|
+
# Check for 'important' (9 chars)
|
|
422
|
+
return false if i < 8 || value[(i - 8), 9] != 'important'
|
|
448
423
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
val_end = pos
|
|
424
|
+
i -= 9
|
|
425
|
+
# Skip whitespace between '!' and 'important'
|
|
426
|
+
while i >= 0 && whitespace?(value.getbyte(i))
|
|
427
|
+
i -= 1
|
|
428
|
+
end
|
|
455
429
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
430
|
+
# Check for '!'
|
|
431
|
+
return false unless i >= 0 && value.getbyte(i) == BYTE_BANG
|
|
432
|
+
|
|
433
|
+
# Remove everything from '!' onwards, in place
|
|
434
|
+
value.slice!(i..-1)
|
|
435
|
+
value.strip!
|
|
436
|
+
true
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Parse a single CSS declaration (property: value)
|
|
440
|
+
#
|
|
441
|
+
# Performance-critical helper that parses one declaration.
|
|
442
|
+
# Shared by parse_mixed_block and parse_declarations_block.
|
|
443
|
+
#
|
|
444
|
+
# @param pos [Integer] Current position in CSS string
|
|
445
|
+
# @param end_pos [Integer] End position (boundary for parsing)
|
|
446
|
+
# @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
|
|
447
|
+
# @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
|
|
448
|
+
def parse_single_declaration(pos, end_pos, parse_important)
|
|
449
|
+
# Parse property name (scan until ':').
|
|
450
|
+
# Also stops at '{' - a property name can never legitimately contain
|
|
451
|
+
# one, so its presence means this is actually an unsupported/invalid
|
|
452
|
+
# nested selector (e.g. a bare type selector without '&') that wasn't
|
|
453
|
+
# recognized as nesting. Treating it as malformed here (instead of
|
|
454
|
+
# scanning through the '{' looking for a colon) keeps its matching '}'
|
|
455
|
+
# from being silently swallowed later, which was corrupting output
|
|
456
|
+
# with unbalanced braces.
|
|
457
|
+
prop_start = pos
|
|
458
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
459
|
+
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE &&
|
|
460
|
+
@_css.getbyte(pos) != BYTE_LBRACE
|
|
461
|
+
pos += 1
|
|
462
|
+
end
|
|
460
463
|
|
|
461
|
-
|
|
464
|
+
# Skip if malformed (no colon found)
|
|
465
|
+
if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
|
|
466
|
+
# Error recovery: skip to next semicolon
|
|
467
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
|
|
468
|
+
pos += 1
|
|
469
|
+
end
|
|
470
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
471
|
+
return [nil, pos]
|
|
472
|
+
end
|
|
462
473
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
value = value[0, value.length - 10].rstrip
|
|
469
|
-
end
|
|
474
|
+
# Trim trailing whitespace from property
|
|
475
|
+
prop_end = pos
|
|
476
|
+
while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
|
|
477
|
+
prop_end -= 1
|
|
478
|
+
end
|
|
470
479
|
|
|
471
|
-
|
|
472
|
-
|
|
480
|
+
# Extract and normalize property name
|
|
481
|
+
property = byteslice_encoded(prop_start, prop_end - prop_start)
|
|
482
|
+
# Custom properties (--foo) are case-sensitive and can contain Unicode
|
|
483
|
+
# Regular properties are ASCII-only and case-insensitive
|
|
484
|
+
unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
|
|
485
|
+
property.force_encoding('US-ASCII')
|
|
486
|
+
property.downcase!
|
|
487
|
+
end
|
|
473
488
|
|
|
474
|
-
|
|
475
|
-
return [nil, pos] if prop_end <= prop_start || val_end <= val_start
|
|
489
|
+
pos += 1 # Skip ':'
|
|
476
490
|
|
|
477
|
-
|
|
478
|
-
|
|
491
|
+
# Skip leading whitespace in value
|
|
492
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
493
|
+
pos += 1
|
|
494
|
+
end
|
|
479
495
|
|
|
480
|
-
|
|
481
|
-
|
|
496
|
+
# Parse value (scan until ';' outside parens, or '}'). Paren depth
|
|
497
|
+
# tracking keeps a ';' inside url(...)/rgba(...) from ending the value
|
|
498
|
+
# early - e.g. "url(data:image/svg+xml;base64,...)".
|
|
499
|
+
val_start = pos
|
|
500
|
+
paren_depth = 0
|
|
501
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_RBRACE
|
|
502
|
+
byte = @_css.getbyte(pos)
|
|
503
|
+
if byte == BYTE_LPAREN
|
|
504
|
+
paren_depth += 1
|
|
505
|
+
elsif byte == BYTE_RPAREN
|
|
506
|
+
paren_depth -= 1
|
|
507
|
+
elsif byte == BYTE_SEMICOLON && paren_depth == 0
|
|
508
|
+
break
|
|
509
|
+
end
|
|
510
|
+
pos += 1
|
|
511
|
+
end
|
|
512
|
+
val_end = pos
|
|
482
513
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
# - Checking RBRACE before LBRACE is faster because closing braces are
|
|
488
|
-
# encountered more frequently when searching forward from an opening brace
|
|
489
|
-
# - Combined optimizations: baseline 666ms → optimized 652ms (2% improvement)
|
|
490
|
-
#
|
|
491
|
-
# Translated from C: see ext/cataract/css_parser.c find_matching_brace
|
|
492
|
-
def find_matching_brace(start_pos)
|
|
493
|
-
depth = 1
|
|
494
|
-
pos = start_pos
|
|
495
|
-
|
|
496
|
-
while pos < @_len
|
|
497
|
-
byte = @_css.getbyte(pos)
|
|
498
|
-
if byte == BYTE_RBRACE
|
|
499
|
-
depth -= 1
|
|
500
|
-
return pos if depth == 0
|
|
501
|
-
elsif byte == BYTE_LBRACE
|
|
502
|
-
depth += 1
|
|
503
|
-
end
|
|
504
|
-
pos += 1
|
|
505
|
-
end
|
|
514
|
+
# Trim trailing whitespace from value
|
|
515
|
+
while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
|
|
516
|
+
val_end -= 1
|
|
517
|
+
end
|
|
506
518
|
|
|
507
|
-
|
|
508
|
-
if @_check_unclosed_blocks && depth > 0
|
|
509
|
-
raise ParseError.new('Unclosed block: missing closing brace',
|
|
510
|
-
css: @_css, pos: start_pos - 1, type: :unclosed_block)
|
|
511
|
-
end
|
|
519
|
+
value = byteslice_encoded(val_start, val_end - val_start)
|
|
512
520
|
|
|
513
|
-
|
|
514
|
-
|
|
521
|
+
# Parse !important flag if requested
|
|
522
|
+
important = parse_important && extract_important!(value)
|
|
515
523
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
start_pos = @_pos
|
|
524
|
+
# Skip semicolon if present
|
|
525
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
519
526
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
@_pos += 1
|
|
523
|
-
end
|
|
527
|
+
# Return nil if empty declaration
|
|
528
|
+
return [nil, pos] if prop_end <= prop_start || val_end <= val_start
|
|
524
529
|
|
|
525
|
-
|
|
526
|
-
|
|
530
|
+
# Convert relative URLs to absolute if enabled
|
|
531
|
+
value = convert_urls_in_value(value)
|
|
527
532
|
|
|
528
|
-
|
|
529
|
-
|
|
533
|
+
[Declaration.new(property, value, important), pos]
|
|
534
|
+
end
|
|
530
535
|
|
|
531
|
-
|
|
532
|
-
|
|
536
|
+
# Find matching closing brace
|
|
537
|
+
#
|
|
538
|
+
# Performance notes (benchmarked on bootstrap.css with 2,400 braces):
|
|
539
|
+
# - Using `return` instead of `break` avoids catch table overhead (~2% faster)
|
|
540
|
+
# - Checking RBRACE before LBRACE is faster because closing braces are
|
|
541
|
+
# encountered more frequently when searching forward from an opening brace
|
|
542
|
+
# - Combined optimizations: baseline 666ms → optimized 652ms (2% improvement)
|
|
543
|
+
#
|
|
544
|
+
# Translated from C: see ext/cataract/css_parser.c find_matching_brace
|
|
545
|
+
def find_matching_brace(start_pos)
|
|
546
|
+
depth = 1
|
|
547
|
+
pos = start_pos
|
|
548
|
+
|
|
549
|
+
while pos < @_len
|
|
550
|
+
byte = @_css.getbyte(pos)
|
|
551
|
+
if byte == BYTE_RBRACE
|
|
552
|
+
depth -= 1
|
|
553
|
+
return pos if depth == 0
|
|
554
|
+
elsif byte == BYTE_LBRACE
|
|
555
|
+
depth += 1
|
|
556
|
+
end
|
|
557
|
+
pos += 1
|
|
558
|
+
end
|
|
533
559
|
|
|
534
|
-
|
|
535
|
-
|
|
560
|
+
# Reached EOF without finding matching closing brace
|
|
561
|
+
if @_check_unclosed_blocks && depth > 0
|
|
562
|
+
raise ParseError.new('Unclosed block: missing closing brace',
|
|
563
|
+
css: @_css, pos: start_pos - 1, type: :unclosed_block)
|
|
564
|
+
end
|
|
536
565
|
|
|
537
|
-
|
|
538
|
-
if @_check_invalid_selectors
|
|
539
|
-
# Check for empty selector
|
|
540
|
-
if selector_text.empty?
|
|
541
|
-
raise ParseError.new('Invalid selector: empty selector',
|
|
542
|
-
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
566
|
+
pos
|
|
543
567
|
end
|
|
544
568
|
|
|
545
|
-
#
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
569
|
+
# Parse selector (read until '{')
|
|
570
|
+
# Advance @_pos to the next unescaped '{', or through to EOF if none is
|
|
571
|
+
# found. Doesn't consume the brace itself. Shared by parse_selector and
|
|
572
|
+
# scan_at_rule_selector, which both need this same scan but differ in
|
|
573
|
+
# what they do with the boundary afterward (validation, trimming, what
|
|
574
|
+
# to return on EOF).
|
|
575
|
+
#
|
|
576
|
+
# @return [Boolean] true if '{' was found, false if EOF was hit first
|
|
577
|
+
def skip_to_opening_brace # rubocop:disable Naming/PredicateMethod
|
|
578
|
+
until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
579
|
+
@_pos += 1
|
|
580
|
+
end
|
|
581
|
+
!eof?
|
|
550
582
|
end
|
|
551
|
-
end
|
|
552
583
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
|
|
556
|
-
css: @_css, pos: start_pos, type: :invalid_selector_syntax)
|
|
557
|
-
end
|
|
584
|
+
def parse_selector
|
|
585
|
+
start_pos = @_pos
|
|
558
586
|
|
|
559
|
-
|
|
560
|
-
|
|
587
|
+
# If we hit EOF without finding '{', return nil
|
|
588
|
+
return nil unless skip_to_opening_brace
|
|
561
589
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
# Returns: Array of declarations (only the declarations, not nested rules)
|
|
565
|
-
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
|
|
566
|
-
# Check recursion depth to prevent stack overflow
|
|
567
|
-
if @_depth > MAX_PARSE_DEPTH
|
|
568
|
-
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
569
|
-
end
|
|
590
|
+
# Extract selector text
|
|
591
|
+
selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
|
|
570
592
|
|
|
571
|
-
|
|
572
|
-
|
|
593
|
+
# Skip the '{'
|
|
594
|
+
@_pos += 1 if peek_byte == BYTE_LBRACE
|
|
573
595
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
577
|
-
pos += 1
|
|
578
|
-
end
|
|
579
|
-
break if pos >= end_pos
|
|
596
|
+
# Trim whitespace from selector (in-place to avoid allocation)
|
|
597
|
+
selector_text.strip!
|
|
580
598
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
break
|
|
599
|
+
# Validate selector (strict mode) - only if enabled to avoid overhead
|
|
600
|
+
if @_check_invalid_selectors
|
|
601
|
+
# Check for empty selector
|
|
602
|
+
if selector_text.empty?
|
|
603
|
+
raise ParseError.new('Invalid selector: empty selector',
|
|
604
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
588
605
|
end
|
|
589
|
-
pos += 1
|
|
590
|
-
end
|
|
591
|
-
next
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
# Check if this is a nested @media query
|
|
595
|
-
if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
|
|
596
|
-
byteslice_encoded(pos, 6) == '@media' &&
|
|
597
|
-
(pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
|
|
598
|
-
# Nested @media - parse with parent selector as context
|
|
599
|
-
media_start = pos + 6
|
|
600
|
-
while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
|
|
601
|
-
media_start += 1
|
|
602
|
-
end
|
|
603
606
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
607
|
+
# Check if selector starts with a combinator (>, +, ~)
|
|
608
|
+
first_char = selector_text.getbyte(0)
|
|
609
|
+
if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
|
|
610
|
+
raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
|
|
611
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
612
|
+
end
|
|
608
613
|
end
|
|
609
|
-
break if media_query_end >= end_pos
|
|
610
614
|
|
|
611
|
-
#
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
+
# Check selector syntax (whitelist validation for invalid characters/sequences)
|
|
616
|
+
if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
|
|
617
|
+
raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
|
|
618
|
+
css: @_css, pos: start_pos, type: :invalid_selector_syntax)
|
|
615
619
|
end
|
|
616
|
-
media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
|
|
617
|
-
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
618
|
-
media_query_str.strip!
|
|
619
|
-
media_sym = media_query_str.to_sym
|
|
620
|
-
|
|
621
|
-
pos = media_query_end + 1 # Skip {
|
|
622
|
-
|
|
623
|
-
# Find matching closing brace
|
|
624
|
-
media_block_start = pos
|
|
625
|
-
media_block_end = find_matching_brace(pos)
|
|
626
|
-
pos = media_block_end
|
|
627
|
-
pos += 1 if pos < end_pos # Skip }
|
|
628
|
-
|
|
629
|
-
# Combine media queries: parent + child
|
|
630
|
-
combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
|
|
631
|
-
|
|
632
|
-
# Create MediaQuery object for this nested @media
|
|
633
|
-
# If we're already in a media query context, combine with parent
|
|
634
|
-
nested_media_query_id = if parent_media_query_id
|
|
635
|
-
# Combine with parent MediaQuery
|
|
636
|
-
parent_mq = @media_queries[parent_media_query_id]
|
|
637
|
-
|
|
638
|
-
# This should never happen - parent_media_query_id should always be valid
|
|
639
|
-
if parent_mq.nil?
|
|
640
|
-
raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
|
|
641
|
-
end
|
|
642
|
-
|
|
643
|
-
# Combine parent media query with child
|
|
644
|
-
_child_type, child_conditions = parse_media_query_parts(media_query_str)
|
|
645
|
-
combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
|
|
646
|
-
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
647
|
-
@media_queries << combined_mq
|
|
648
|
-
combined_id = @_media_query_id_counter
|
|
649
|
-
@_media_query_id_counter += 1
|
|
650
|
-
combined_id
|
|
651
|
-
else
|
|
652
|
-
# No parent context, just use the child media query
|
|
653
|
-
media_type, media_conditions = parse_media_query_parts(media_query_str)
|
|
654
|
-
nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
655
|
-
@media_queries << nested_media_query
|
|
656
|
-
mq_id = @_media_query_id_counter
|
|
657
|
-
@_media_query_id_counter += 1
|
|
658
|
-
mq_id
|
|
659
|
-
end
|
|
660
|
-
|
|
661
|
-
# Create rule ID for this media rule
|
|
662
|
-
media_rule_id = @_rule_id_counter
|
|
663
|
-
@_rule_id_counter += 1
|
|
664
620
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
@rules << nil # Placeholder
|
|
668
|
-
|
|
669
|
-
# Parse mixed block recursively with the nested media query ID as context
|
|
670
|
-
@_depth += 1
|
|
671
|
-
media_declarations = parse_mixed_block(media_block_start, media_block_end,
|
|
672
|
-
parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
|
|
673
|
-
@_depth -= 1
|
|
621
|
+
selector_text
|
|
622
|
+
end
|
|
674
623
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
nil, # selector_list_id
|
|
684
|
-
nested_media_query_id # media_query_id
|
|
685
|
-
)
|
|
624
|
+
# Parse mixed block containing declarations AND nested selectors/at-rules
|
|
625
|
+
# Translated from C: see ext/cataract/css_parser.c parse_mixed_block
|
|
626
|
+
# Returns: Array of declarations (only the declarations, not nested rules)
|
|
627
|
+
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
|
|
628
|
+
# Check recursion depth to prevent stack overflow
|
|
629
|
+
if @_depth > MAX_PARSE_DEPTH
|
|
630
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
631
|
+
end
|
|
686
632
|
|
|
687
|
-
|
|
688
|
-
|
|
633
|
+
declarations = []
|
|
634
|
+
pos = start_pos
|
|
689
635
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
636
|
+
while pos < end_pos
|
|
637
|
+
# Skip whitespace and comments
|
|
638
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
639
|
+
pos += 1
|
|
640
|
+
end
|
|
641
|
+
break if pos >= end_pos
|
|
694
642
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
643
|
+
# Skip comments
|
|
644
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
645
|
+
pos += 2
|
|
646
|
+
while pos + 1 < end_pos
|
|
647
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
648
|
+
pos += 2
|
|
649
|
+
break
|
|
650
|
+
end
|
|
651
|
+
pos += 1
|
|
652
|
+
end
|
|
653
|
+
next
|
|
654
|
+
end
|
|
706
655
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
656
|
+
# Check if this is a nested @media query
|
|
657
|
+
if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
|
|
658
|
+
byteslice_encoded(pos, 6) == '@media' &&
|
|
659
|
+
(pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
|
|
660
|
+
# Nested @media - parse with parent selector as context
|
|
661
|
+
media_start = pos + 6
|
|
662
|
+
while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
|
|
663
|
+
media_start += 1
|
|
664
|
+
end
|
|
712
665
|
|
|
713
|
-
|
|
666
|
+
# Find opening brace
|
|
667
|
+
media_query_end = media_start
|
|
668
|
+
while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
|
|
669
|
+
media_query_end += 1
|
|
670
|
+
end
|
|
671
|
+
break if media_query_end >= end_pos
|
|
714
672
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
673
|
+
# Extract media query (trim trailing whitespace)
|
|
674
|
+
media_query_end_trimmed = media_query_end
|
|
675
|
+
while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
|
|
676
|
+
media_query_end_trimmed -= 1
|
|
677
|
+
end
|
|
678
|
+
media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
|
|
679
|
+
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
680
|
+
media_query_str.strip!
|
|
681
|
+
media_sym = media_query_str.to_sym
|
|
682
|
+
|
|
683
|
+
pos = media_query_end + 1 # Skip {
|
|
684
|
+
|
|
685
|
+
# Find matching closing brace
|
|
686
|
+
media_block_start = pos
|
|
687
|
+
media_block_end = find_matching_brace(pos)
|
|
688
|
+
pos = media_block_end
|
|
689
|
+
pos += 1 if pos < end_pos # Skip }
|
|
690
|
+
|
|
691
|
+
# Combine media queries: parent + child
|
|
692
|
+
combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
|
|
693
|
+
|
|
694
|
+
# Create MediaQuery object for this nested @media
|
|
695
|
+
# If we're already in a media query context, combine with parent
|
|
696
|
+
nested_media_query_id = if parent_media_query_id
|
|
697
|
+
# Combine with parent MediaQuery
|
|
698
|
+
parent_mq = @media_queries[parent_media_query_id]
|
|
699
|
+
|
|
700
|
+
# This should never happen - parent_media_query_id should always be valid
|
|
701
|
+
if parent_mq.nil?
|
|
702
|
+
raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Combine parent media query with child
|
|
706
|
+
_child_type, child_conditions = parse_media_query_parts(media_query_str)
|
|
707
|
+
combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
|
|
708
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
709
|
+
@media_queries << combined_mq
|
|
710
|
+
combined_id = @_media_query_id_counter
|
|
711
|
+
@_media_query_id_counter += 1
|
|
712
|
+
combined_id
|
|
713
|
+
else
|
|
714
|
+
# No parent context, just use the child media query
|
|
715
|
+
media_type, media_conditions = parse_media_query_parts(media_query_str)
|
|
716
|
+
nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
717
|
+
@media_queries << nested_media_query
|
|
718
|
+
mq_id = @_media_query_id_counter
|
|
719
|
+
@_media_query_id_counter += 1
|
|
720
|
+
mq_id
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Create rule ID for this media rule
|
|
724
|
+
media_rule_id = @_rule_id_counter
|
|
725
|
+
@_rule_id_counter += 1
|
|
726
|
+
|
|
727
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
728
|
+
rule_position = @rules.length
|
|
729
|
+
@rules << nil # Placeholder
|
|
730
|
+
|
|
731
|
+
# Parse mixed block recursively with the nested media query ID as context
|
|
732
|
+
@_depth += 1
|
|
733
|
+
media_declarations = parse_mixed_block(media_block_start, media_block_end,
|
|
734
|
+
parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
|
|
735
|
+
@_depth -= 1
|
|
736
|
+
|
|
737
|
+
# Create rule with parent selector and declarations, associated with combined media query
|
|
738
|
+
rule = Rule.new(
|
|
739
|
+
media_rule_id,
|
|
740
|
+
parent_selector,
|
|
741
|
+
media_declarations,
|
|
742
|
+
nil, # specificity
|
|
743
|
+
parent_rule_id,
|
|
744
|
+
nil, # nesting_style (nil for @media nesting)
|
|
745
|
+
nil, # selector_list_id
|
|
746
|
+
nested_media_query_id # media_query_id
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# Mark that we have nesting
|
|
750
|
+
@_has_nesting = true unless parent_rule_id.nil?
|
|
751
|
+
|
|
752
|
+
# Replace placeholder with actual rule
|
|
753
|
+
@rules[rule_position] = rule
|
|
754
|
+
next
|
|
755
|
+
end
|
|
720
756
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
757
|
+
# Check if this is a nested selector
|
|
758
|
+
byte = @_css.getbyte(pos)
|
|
759
|
+
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
760
|
+
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
761
|
+
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
|
|
762
|
+
# Find the opening brace
|
|
763
|
+
nested_sel_start = pos
|
|
764
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
|
|
765
|
+
pos += 1
|
|
766
|
+
end
|
|
767
|
+
break if pos >= end_pos
|
|
724
768
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
769
|
+
nested_sel_end = pos
|
|
770
|
+
# Trim trailing whitespace
|
|
771
|
+
while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
|
|
772
|
+
nested_sel_end -= 1
|
|
773
|
+
end
|
|
728
774
|
|
|
729
|
-
|
|
730
|
-
|
|
775
|
+
pos += 1 # Skip {
|
|
776
|
+
|
|
777
|
+
# Find matching closing brace
|
|
778
|
+
nested_block_start = pos
|
|
779
|
+
nested_block_end = find_matching_brace(pos)
|
|
780
|
+
pos = nested_block_end
|
|
781
|
+
pos += 1 if pos < end_pos # Skip }
|
|
782
|
+
|
|
783
|
+
# Extract nested selector and split on commas
|
|
784
|
+
nested_selector_text = byteslice_encoded(nested_sel_start, nested_sel_end - nested_sel_start)
|
|
785
|
+
nested_selectors = nested_selector_text.split(',')
|
|
786
|
+
|
|
787
|
+
nested_selectors.each do |seg|
|
|
788
|
+
seg.strip!
|
|
789
|
+
next if seg.empty?
|
|
790
|
+
|
|
791
|
+
# Resolve nested selector
|
|
792
|
+
resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
|
|
793
|
+
|
|
794
|
+
# Get rule ID
|
|
795
|
+
rule_id = @_rule_id_counter
|
|
796
|
+
@_rule_id_counter += 1
|
|
797
|
+
|
|
798
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
799
|
+
rule_position = @rules.length
|
|
800
|
+
@rules << nil # Placeholder
|
|
801
|
+
|
|
802
|
+
# Recursively parse nested block
|
|
803
|
+
@_depth += 1
|
|
804
|
+
nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
|
|
805
|
+
resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
|
|
806
|
+
@_depth -= 1
|
|
807
|
+
|
|
808
|
+
# Create rule for nested selector
|
|
809
|
+
rule = Rule.new(
|
|
810
|
+
rule_id,
|
|
811
|
+
resolved_selector,
|
|
812
|
+
nested_declarations,
|
|
813
|
+
nil, # specificity
|
|
814
|
+
parent_rule_id,
|
|
815
|
+
nesting_style
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Mark that we have nesting
|
|
819
|
+
@_has_nesting = true unless parent_rule_id.nil?
|
|
820
|
+
|
|
821
|
+
# Replace placeholder with actual rule
|
|
822
|
+
@rules[rule_position] = rule
|
|
823
|
+
end
|
|
731
824
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
@_rule_id_counter += 1
|
|
825
|
+
next
|
|
826
|
+
end
|
|
735
827
|
|
|
736
|
-
#
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
# Recursively parse nested block
|
|
741
|
-
@_depth += 1
|
|
742
|
-
nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
|
|
743
|
-
resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
|
|
744
|
-
@_depth -= 1
|
|
745
|
-
|
|
746
|
-
# Create rule for nested selector
|
|
747
|
-
rule = Rule.new(
|
|
748
|
-
rule_id,
|
|
749
|
-
resolved_selector,
|
|
750
|
-
nested_declarations,
|
|
751
|
-
nil, # specificity
|
|
752
|
-
parent_rule_id,
|
|
753
|
-
nesting_style
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
# Mark that we have nesting
|
|
757
|
-
@_has_nesting = true unless parent_rule_id.nil?
|
|
758
|
-
|
|
759
|
-
# Replace placeholder with actual rule
|
|
760
|
-
@rules[rule_position] = rule
|
|
828
|
+
# This is a declaration - parse it using shared helper
|
|
829
|
+
decl, pos = parse_single_declaration(pos, end_pos, true)
|
|
830
|
+
declarations << decl if decl
|
|
761
831
|
end
|
|
762
832
|
|
|
763
|
-
|
|
833
|
+
declarations
|
|
764
834
|
end
|
|
765
835
|
|
|
766
|
-
#
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
836
|
+
# Parse declaration block (inside { ... })
|
|
837
|
+
# Assumes we're already past the opening '{'
|
|
838
|
+
def parse_declarations
|
|
839
|
+
declarations = []
|
|
770
840
|
|
|
771
|
-
|
|
772
|
-
|
|
841
|
+
# Read until we find the closing '}'
|
|
842
|
+
until eof?
|
|
843
|
+
skip_ws_and_comments
|
|
844
|
+
break if eof?
|
|
773
845
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
846
|
+
# Check for closing brace
|
|
847
|
+
if peek_byte == BYTE_RBRACE
|
|
848
|
+
@_pos += 1 # consume '}'
|
|
849
|
+
break
|
|
850
|
+
end
|
|
778
851
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
852
|
+
# Parse property name (read until ':'). Also stops at '{' - a property
|
|
853
|
+
# name can never legitimately contain one, so its presence means this
|
|
854
|
+
# is actually an unsupported/invalid nested selector (e.g. a bare type
|
|
855
|
+
# selector without '&') that wasn't recognized as nesting. Treating it
|
|
856
|
+
# as malformed here (instead of scanning through the '{' looking for a
|
|
857
|
+
# colon) keeps its matching '}' from being silently swallowed later,
|
|
858
|
+
# which was corrupting output with unbalanced braces.
|
|
859
|
+
property_start = @_pos
|
|
860
|
+
until eof?
|
|
861
|
+
byte = peek_byte
|
|
862
|
+
break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE || byte == BYTE_LBRACE
|
|
783
863
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
@_pos += 1 # consume '}'
|
|
787
|
-
break
|
|
788
|
-
end
|
|
864
|
+
@_pos += 1
|
|
865
|
+
end
|
|
789
866
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
867
|
+
# Skip if no colon found (malformed)
|
|
868
|
+
if eof? || peek_byte != BYTE_COLON
|
|
869
|
+
# Check for malformed declaration (strict mode)
|
|
870
|
+
if @_check_malformed_declarations
|
|
871
|
+
property_text = byteslice_encoded(property_start, @_pos - property_start).strip
|
|
872
|
+
if property_text.empty?
|
|
873
|
+
raise ParseError.new('Malformed declaration: missing property name',
|
|
874
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
875
|
+
else
|
|
876
|
+
raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
|
|
877
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
878
|
+
end
|
|
879
|
+
end
|
|
795
880
|
|
|
796
|
-
|
|
797
|
-
|
|
881
|
+
# Try to recover by finding next ; or }
|
|
882
|
+
skip_to_semicolon_or_brace
|
|
883
|
+
next
|
|
884
|
+
end
|
|
798
885
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
|
|
809
|
-
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
886
|
+
# Extract property name - use UTF-8 encoding to support custom properties with Unicode
|
|
887
|
+
property = byteslice_encoded(property_start, @_pos - property_start)
|
|
888
|
+
property.strip!
|
|
889
|
+
# Custom properties (--foo) are case-sensitive and can contain Unicode
|
|
890
|
+
# Regular properties are ASCII-only and case-insensitive
|
|
891
|
+
unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
|
|
892
|
+
# Regular property: force ASCII encoding and downcase
|
|
893
|
+
property.force_encoding('US-ASCII')
|
|
894
|
+
property.downcase!
|
|
810
895
|
end
|
|
811
|
-
|
|
896
|
+
@_pos += 1 # skip ':'
|
|
897
|
+
|
|
898
|
+
skip_ws_and_comments
|
|
899
|
+
|
|
900
|
+
# Parse value (read until ';' outside parens, or '}', but respect
|
|
901
|
+
# quoted strings). Paren depth tracking keeps a ';' inside
|
|
902
|
+
# url(...)/rgba(...) from ending the value early - e.g.
|
|
903
|
+
# "url(data:image/svg+xml;base64,...)".
|
|
904
|
+
value_start = @_pos
|
|
905
|
+
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
906
|
+
paren_depth = 0
|
|
907
|
+
|
|
908
|
+
until eof?
|
|
909
|
+
byte = peek_byte
|
|
910
|
+
|
|
911
|
+
if in_quote
|
|
912
|
+
# Inside quoted string - only exit on matching quote
|
|
913
|
+
if byte == in_quote
|
|
914
|
+
in_quote = nil
|
|
915
|
+
elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
916
|
+
# Skip escaped character
|
|
917
|
+
@_pos += 1
|
|
918
|
+
end
|
|
919
|
+
else
|
|
920
|
+
# Not in quote - check for terminators or quote/paren start
|
|
921
|
+
break if byte == BYTE_RBRACE || (byte == BYTE_SEMICOLON && paren_depth == 0)
|
|
922
|
+
|
|
923
|
+
# case/when compiles to opt_send(===) here, not opt_eq - benchmarked
|
|
924
|
+
# ~1.7x slower without YJIT, still slower with it.
|
|
925
|
+
if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE # rubocop:disable Style/CaseLikeIf
|
|
926
|
+
in_quote = byte
|
|
927
|
+
elsif byte == BYTE_LPAREN
|
|
928
|
+
paren_depth += 1
|
|
929
|
+
elsif byte == BYTE_RPAREN
|
|
930
|
+
paren_depth -= 1
|
|
931
|
+
end
|
|
932
|
+
end
|
|
812
933
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
next
|
|
816
|
-
end
|
|
934
|
+
@_pos += 1
|
|
935
|
+
end
|
|
817
936
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
property.strip!
|
|
821
|
-
# Custom properties (--foo) are case-sensitive and can contain Unicode
|
|
822
|
-
# Regular properties are ASCII-only and case-insensitive
|
|
823
|
-
unless property.bytesize >= 2 && property.getbyte(0) == BYTE_HYPHEN && property.getbyte(1) == BYTE_HYPHEN
|
|
824
|
-
# Regular property: force ASCII encoding and downcase
|
|
825
|
-
property.force_encoding('US-ASCII')
|
|
826
|
-
property.downcase!
|
|
827
|
-
end
|
|
828
|
-
@_pos += 1 # skip ':'
|
|
937
|
+
value = byteslice_encoded(value_start, @_pos - value_start)
|
|
938
|
+
value.strip!
|
|
829
939
|
|
|
830
|
-
|
|
940
|
+
# Check for !important (byte-by-byte, no regexp)
|
|
941
|
+
important = extract_important!(value)
|
|
831
942
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
943
|
+
# Check for empty value (strict mode) - only if enabled to avoid overhead
|
|
944
|
+
if @_check_empty_values && value.empty?
|
|
945
|
+
raise ParseError.new("Empty value for property '#{property}'",
|
|
946
|
+
css: @_css, pos: property_start, type: :empty_value)
|
|
947
|
+
end
|
|
836
948
|
|
|
837
|
-
|
|
838
|
-
|
|
949
|
+
# Skip semicolon if present
|
|
950
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
839
951
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
if byte == in_quote
|
|
843
|
-
in_quote = nil
|
|
844
|
-
elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
845
|
-
# Skip escaped character
|
|
846
|
-
@_pos += 1
|
|
847
|
-
end
|
|
848
|
-
else
|
|
849
|
-
# Not in quote - check for terminators or quote start
|
|
850
|
-
break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
952
|
+
# Convert relative URLs to absolute if enabled
|
|
953
|
+
value = convert_urls_in_value(value)
|
|
851
954
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
end
|
|
955
|
+
# Create Declaration struct
|
|
956
|
+
declarations << Declaration.new(property, value, important)
|
|
855
957
|
end
|
|
856
958
|
|
|
857
|
-
|
|
959
|
+
declarations
|
|
858
960
|
end
|
|
859
961
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
#
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
962
|
+
# Scan from at_rule_start (pointing at the '@') to the at-rule's opening
|
|
963
|
+
# brace, returning the trimmed selector text (e.g. "@keyframes fade").
|
|
964
|
+
# Leaves @_pos just past the opening brace. Shared by at-rule kinds whose
|
|
965
|
+
# selector is just "everything up to '{'" with no separate condition/query
|
|
966
|
+
# text to extract along the way (@keyframes, @font-face, and the unknown/
|
|
967
|
+
# default at-rule fallback).
|
|
968
|
+
#
|
|
969
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
970
|
+
# @return [String, nil] The trimmed selector, or nil if '{' was never found
|
|
971
|
+
def scan_at_rule_selector(at_rule_start)
|
|
972
|
+
return nil unless skip_to_opening_brace
|
|
871
973
|
|
|
872
|
-
|
|
974
|
+
selector_end = @_pos
|
|
975
|
+
while selector_end > at_rule_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
976
|
+
selector_end -= 1
|
|
873
977
|
end
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
978
|
+
selector = byteslice_encoded(at_rule_start, selector_end - at_rule_start)
|
|
979
|
+
|
|
980
|
+
@_pos += 1 # skip '{'
|
|
981
|
+
selector
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Merge selector_lists from a nested Parser's result into this parser,
|
|
985
|
+
# offsetting list ids and rule ids to avoid collisions. Shared by the
|
|
986
|
+
# conditional-group (@supports/@layer/@container/@scope) and @media
|
|
987
|
+
# at-rule handlers, which both recurse into a fresh Parser instance for
|
|
988
|
+
# their block content.
|
|
989
|
+
#
|
|
990
|
+
# @param nested_result [Hash] Result hash from a nested Parser#parse call
|
|
991
|
+
# @return [Integer] The list_id_offset used - pass through to
|
|
992
|
+
# merge_nested_rules so each rule's selector_list_id stays consistent
|
|
993
|
+
def merge_nested_selector_lists(nested_result)
|
|
994
|
+
list_id_offset = @_next_selector_list_id
|
|
995
|
+
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
996
|
+
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
997
|
+
new_list_id = list_id + list_id_offset
|
|
998
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
999
|
+
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
884
1000
|
end
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1001
|
+
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1002
|
+
end
|
|
1003
|
+
list_id_offset
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# Merge rules from a nested Parser's result into this parser's @rules,
|
|
1007
|
+
# renumbering ids and offsetting selector_list_id, then propagate whether
|
|
1008
|
+
# nesting was found inside the block up to this parser - the nested
|
|
1009
|
+
# parser tracked its own independent @_has_nesting, so without this a
|
|
1010
|
+
# rule nested inside a top-level at-rule block would carry a correct
|
|
1011
|
+
# parent_rule_id but the stylesheet would still report has_nesting:
|
|
1012
|
+
# false, causing serialization to use the flat, non-nesting-aware path
|
|
1013
|
+
# and print the rule's already-resolved selector as an unrelated
|
|
1014
|
+
# top-level rule instead of reconstructing the nested syntax.
|
|
1015
|
+
#
|
|
1016
|
+
# Yields each rule (after id/selector_list_id are updated, before it's
|
|
1017
|
+
# appended to @rules) for callers needing extra per-rule handling - e.g.
|
|
1018
|
+
# @media combining media_query_id with its outer media context.
|
|
1019
|
+
#
|
|
1020
|
+
# @param nested_result [Hash] Result hash from a nested Parser#parse call
|
|
1021
|
+
# @param list_id_offset [Integer] Offset from merge_nested_selector_lists
|
|
1022
|
+
def merge_nested_rules(nested_result, list_id_offset)
|
|
1023
|
+
nested_result[:rules].each do |rule|
|
|
1024
|
+
rule.id = @_rule_id_counter
|
|
1025
|
+
if rule.is_a?(Rule) && rule.selector_list_id
|
|
1026
|
+
rule.selector_list_id += list_id_offset
|
|
891
1027
|
end
|
|
1028
|
+
|
|
1029
|
+
yield rule if block_given?
|
|
1030
|
+
|
|
1031
|
+
@_rule_id_counter += 1
|
|
1032
|
+
@rules << rule
|
|
892
1033
|
end
|
|
893
|
-
end
|
|
894
1034
|
|
|
895
|
-
|
|
896
|
-
if @_check_empty_values && value.empty?
|
|
897
|
-
raise ParseError.new("Empty value for property '#{property}'",
|
|
898
|
-
css: @_css, pos: property_start, type: :empty_value)
|
|
1035
|
+
@_has_nesting ||= nested_result[:_has_nesting] # rubocop:disable Naming/MemoizedInstanceVariableName -- not memoization, propagating a flag from the nested parse
|
|
899
1036
|
end
|
|
900
1037
|
|
|
901
|
-
#
|
|
902
|
-
|
|
1038
|
+
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
1039
|
+
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
1040
|
+
def parse_at_rule
|
|
1041
|
+
at_rule_start = @_pos # Points to '@'
|
|
1042
|
+
@_pos += 1 # skip '@'
|
|
903
1043
|
|
|
904
|
-
|
|
905
|
-
|
|
1044
|
+
# Find end of at-rule name (stop at whitespace or opening brace)
|
|
1045
|
+
name_start = @_pos
|
|
1046
|
+
until eof?
|
|
1047
|
+
byte = peek_byte
|
|
1048
|
+
break if whitespace?(byte) || byte == BYTE_LBRACE
|
|
906
1049
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
end
|
|
1050
|
+
@_pos += 1
|
|
1051
|
+
end
|
|
910
1052
|
|
|
911
|
-
|
|
912
|
-
end
|
|
1053
|
+
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
913
1054
|
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
def parse_at_rule
|
|
917
|
-
at_rule_start = @_pos # Points to '@'
|
|
918
|
-
@_pos += 1 # skip '@'
|
|
1055
|
+
# Handle @charset specially - it's just @charset "value";
|
|
1056
|
+
return parse_charset_at_rule if at_rule_name == 'charset'
|
|
919
1057
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
until eof?
|
|
923
|
-
byte = peek_byte
|
|
924
|
-
break if whitespace?(byte) || byte == BYTE_LBRACE
|
|
1058
|
+
# Handle @import - must come before rules (except @charset)
|
|
1059
|
+
return parse_import_at_rule if at_rule_name == 'import'
|
|
925
1060
|
|
|
926
|
-
|
|
927
|
-
|
|
1061
|
+
# Handle conditional group at-rules: @supports, @layer, @container, @scope
|
|
1062
|
+
# These behave like @media but don't affect media context
|
|
1063
|
+
return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
|
|
928
1064
|
|
|
929
|
-
|
|
1065
|
+
# Handle @media specially - parse content and track in media_index
|
|
1066
|
+
return parse_media_at_rule if at_rule_name == 'media'
|
|
930
1067
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
937
|
-
@_pos += 1
|
|
938
|
-
end
|
|
1068
|
+
# Check for @keyframes (contains <rule-list>)
|
|
1069
|
+
is_keyframes = at_rule_name == 'keyframes' ||
|
|
1070
|
+
at_rule_name == '-webkit-keyframes' ||
|
|
1071
|
+
at_rule_name == '-moz-keyframes'
|
|
1072
|
+
return parse_keyframes_at_rule(at_rule_start) if is_keyframes
|
|
939
1073
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
# Remove quotes
|
|
943
|
-
@charset = charset_value.delete('"\'')
|
|
1074
|
+
# Check for @font-face (contains <declaration-list>)
|
|
1075
|
+
return parse_font_face_at_rule(at_rule_start) if at_rule_name == 'font-face'
|
|
944
1076
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1077
|
+
# Unknown at-rule (@property, @page, @counter-style, etc.)
|
|
1078
|
+
# Treat as a regular selector-based rule with declarations
|
|
1079
|
+
parse_unknown_at_rule(at_rule_start)
|
|
1080
|
+
end
|
|
948
1081
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
# Skip to semicolon
|
|
1082
|
+
# @charset "value"; - stores the value and consumes through the semicolon.
|
|
1083
|
+
def parse_charset_at_rule
|
|
1084
|
+
skip_ws_and_comments
|
|
1085
|
+
# Read until semicolon
|
|
1086
|
+
value_start = @_pos
|
|
955
1087
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
956
1088
|
@_pos += 1
|
|
957
1089
|
end
|
|
958
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
959
|
-
return
|
|
960
|
-
end
|
|
961
|
-
|
|
962
|
-
parse_import_statement
|
|
963
|
-
return
|
|
964
|
-
end
|
|
965
|
-
|
|
966
|
-
# Handle conditional group at-rules: @supports, @layer, @container, @scope
|
|
967
|
-
# These behave like @media but don't affect media context
|
|
968
|
-
if AT_RULE_TYPES.include?(at_rule_name)
|
|
969
|
-
skip_ws_and_comments
|
|
970
1090
|
|
|
971
|
-
|
|
972
|
-
|
|
1091
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
1092
|
+
charset_value.strip!
|
|
1093
|
+
# Remove quotes
|
|
1094
|
+
@charset = charset_value.delete('"\'')
|
|
973
1095
|
|
|
974
|
-
|
|
975
|
-
condition_end = @_pos
|
|
976
|
-
while !eof? && peek_byte != BYTE_LBRACE
|
|
977
|
-
condition_end = @_pos
|
|
978
|
-
@_pos += 1
|
|
1096
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
979
1097
|
end
|
|
980
1098
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
#
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
if
|
|
987
|
-
|
|
988
|
-
|
|
1099
|
+
# @import must appear before all rules (except @charset) per CSS spec -
|
|
1100
|
+
# anything else is invalid and gets skipped with a warning instead of
|
|
1101
|
+
# being parsed as an import.
|
|
1102
|
+
def parse_import_at_rule
|
|
1103
|
+
# If we've already seen a rule, this @import is invalid
|
|
1104
|
+
if @rules.size > 0
|
|
1105
|
+
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
1106
|
+
# Skip to semicolon
|
|
1107
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1108
|
+
@_pos += 1
|
|
1109
|
+
end
|
|
1110
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1111
|
+
return
|
|
989
1112
|
end
|
|
990
|
-
end
|
|
991
1113
|
|
|
992
|
-
|
|
1114
|
+
parse_import_statement
|
|
1115
|
+
end
|
|
993
1116
|
|
|
994
|
-
#
|
|
995
|
-
|
|
996
|
-
|
|
1117
|
+
# @supports, @layer, @container, @scope - behave like @media but don't
|
|
1118
|
+
# affect media context, so unlike parse_media_at_rule there's no media
|
|
1119
|
+
# query/media_query_id merging to do on top of the shared nested-parser
|
|
1120
|
+
# result merge.
|
|
1121
|
+
#
|
|
1122
|
+
# @param at_rule_name [String] The at-rule name (e.g. "supports"), used
|
|
1123
|
+
# to decide whether a condition is required and for error messages
|
|
1124
|
+
def parse_conditional_group_at_rule(at_rule_name)
|
|
1125
|
+
skip_ws_and_comments
|
|
997
1126
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1001
|
-
end
|
|
1127
|
+
# Remember start of condition for error reporting
|
|
1128
|
+
condition_start = @_pos
|
|
1002
1129
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
depth: @_depth + 1
|
|
1009
|
-
)
|
|
1010
|
-
|
|
1011
|
-
nested_result = nested_parser.parse
|
|
1012
|
-
|
|
1013
|
-
# Merge nested selector_lists with offsetted IDs
|
|
1014
|
-
list_id_offset = @_next_selector_list_id
|
|
1015
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
1016
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
1017
|
-
new_list_id = list_id + list_id_offset
|
|
1018
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
1019
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
1130
|
+
# Skip to opening brace
|
|
1131
|
+
condition_end = @_pos
|
|
1132
|
+
while !eof? && peek_byte != BYTE_LBRACE
|
|
1133
|
+
condition_end = @_pos
|
|
1134
|
+
@_pos += 1
|
|
1020
1135
|
end
|
|
1021
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1022
|
-
end
|
|
1023
1136
|
|
|
1024
|
-
|
|
1025
|
-
# It will be built from MediaQuery objects after import resolution
|
|
1137
|
+
return if eof? || peek_byte != BYTE_LBRACE
|
|
1026
1138
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1139
|
+
# Validate condition (strict mode) - @supports, @container, @scope require conditions
|
|
1140
|
+
if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
|
|
1141
|
+
condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
|
|
1142
|
+
if condition_str.empty?
|
|
1143
|
+
raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
|
|
1144
|
+
css: @_css, pos: condition_start, type: :malformed_at_rule)
|
|
1145
|
+
end
|
|
1033
1146
|
end
|
|
1034
|
-
@_rule_id_counter += 1
|
|
1035
|
-
@rules << rule
|
|
1036
|
-
end
|
|
1037
1147
|
|
|
1038
|
-
|
|
1039
|
-
@_pos = block_end
|
|
1040
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1148
|
+
@_pos += 1 # skip '{'
|
|
1041
1149
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
# Handle @media specially - parse content and track in media_index
|
|
1046
|
-
if at_rule_name == 'media'
|
|
1047
|
-
skip_ws_and_comments
|
|
1150
|
+
# Find matching closing brace
|
|
1151
|
+
block_start = @_pos
|
|
1152
|
+
block_end = find_matching_brace(@_pos)
|
|
1048
1153
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
end
|
|
1154
|
+
# Check depth before recursing
|
|
1155
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1156
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1157
|
+
end
|
|
1054
1158
|
|
|
1055
|
-
|
|
1159
|
+
# Recursively parse block content (preserve parent media context)
|
|
1160
|
+
nested_parser = Parser.new(
|
|
1161
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1162
|
+
parser_options: @_parser_options,
|
|
1163
|
+
parent_media_sym: @_parent_media_sym,
|
|
1164
|
+
depth: @_depth + 1
|
|
1165
|
+
)
|
|
1056
1166
|
|
|
1057
|
-
|
|
1058
|
-
# Trim trailing whitespace
|
|
1059
|
-
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
1060
|
-
mq_end -= 1
|
|
1061
|
-
end
|
|
1167
|
+
nested_result = nested_parser.parse
|
|
1062
1168
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1169
|
+
# NOTE: We no longer build media_index during parse
|
|
1170
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1171
|
+
list_id_offset = merge_nested_selector_lists(nested_result)
|
|
1172
|
+
merge_nested_rules(nested_result, list_id_offset)
|
|
1066
1173
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
css: @_css, pos: mq_start, type: :malformed_at_rule)
|
|
1174
|
+
# Move position past the closing brace
|
|
1175
|
+
@_pos = block_end
|
|
1176
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1071
1177
|
end
|
|
1072
1178
|
|
|
1073
|
-
|
|
1179
|
+
# @media - parses the media query, recurses into a nested Parser for the
|
|
1180
|
+
# block content (combining its media context with any parent), and
|
|
1181
|
+
# merges the result. Unlike parse_conditional_group_at_rule, this also
|
|
1182
|
+
# merges MediaQuery objects/lists and combines each nested rule's
|
|
1183
|
+
# media_query_id with this block's own media context.
|
|
1184
|
+
def parse_media_at_rule
|
|
1185
|
+
skip_ws_and_comments
|
|
1074
1186
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1187
|
+
# Find media query (up to opening brace)
|
|
1188
|
+
mq_start = @_pos
|
|
1189
|
+
return unless skip_to_opening_brace
|
|
1078
1190
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
@media_queries << media_query
|
|
1085
|
-
media_query_ids << @_media_query_id_counter
|
|
1086
|
-
@_media_query_id_counter += 1
|
|
1087
|
-
end
|
|
1191
|
+
mq_end = @_pos
|
|
1192
|
+
# Trim trailing whitespace
|
|
1193
|
+
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
1194
|
+
mq_end -= 1
|
|
1195
|
+
end
|
|
1088
1196
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
@_next_media_query_list_id += 1
|
|
1093
|
-
end
|
|
1197
|
+
child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
|
|
1198
|
+
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
1199
|
+
child_media_string.strip!
|
|
1094
1200
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1201
|
+
# Validate @media has a query (strict mode)
|
|
1202
|
+
if @_check_malformed_at_rules && child_media_string.empty?
|
|
1203
|
+
raise ParseError.new('Malformed @media: missing media query or condition',
|
|
1204
|
+
css: @_css, pos: mq_start, type: :malformed_at_rule)
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
child_media_sym = child_media_string.to_sym
|
|
1097
1208
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1209
|
+
# Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
|
|
1210
|
+
# Per W3C spec, comma acts as logical OR - each query is independent
|
|
1211
|
+
media_query_strings = child_media_string.split(',').map(&:strip)
|
|
1100
1212
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1213
|
+
# Create MediaQuery objects for each query in the list
|
|
1214
|
+
media_query_ids = []
|
|
1215
|
+
media_query_strings.each do |query_string|
|
|
1216
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1217
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1218
|
+
@media_queries << media_query
|
|
1219
|
+
media_query_ids << @_media_query_id_counter
|
|
1220
|
+
@_media_query_id_counter += 1
|
|
1221
|
+
end
|
|
1106
1222
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
1223
|
+
# If multiple queries, track them as a list for serialization
|
|
1224
|
+
if media_query_ids.size > 1
|
|
1225
|
+
@_media_query_lists[@_next_media_query_list_id] = media_query_ids
|
|
1226
|
+
@_next_media_query_list_id += 1
|
|
1112
1227
|
end
|
|
1113
|
-
end
|
|
1114
1228
|
|
|
1115
|
-
|
|
1229
|
+
# Use first query ID as the primary one for rules in this block
|
|
1230
|
+
current_media_query_id = media_query_ids.first
|
|
1116
1231
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
block_end = find_matching_brace(@_pos)
|
|
1232
|
+
# Combine with parent media context
|
|
1233
|
+
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1120
1234
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1235
|
+
# NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
|
|
1236
|
+
# create separate parsers without passing parent_media_query_id (see nested_parser creation below).
|
|
1237
|
+
# MediaQuery combining for nested @media happens in parse_mixed_block instead.
|
|
1238
|
+
# So this is just an alias to current_media_query_id.
|
|
1239
|
+
combined_media_query_id = current_media_query_id
|
|
1125
1240
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
parent_media_sym: combined_media_sym,
|
|
1133
|
-
depth: @_depth + 1
|
|
1134
|
-
)
|
|
1135
|
-
|
|
1136
|
-
nested_result = nested_parser.parse
|
|
1137
|
-
|
|
1138
|
-
# Merge nested selector_lists with offsetted IDs
|
|
1139
|
-
list_id_offset = @_next_selector_list_id
|
|
1140
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
1141
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
1142
|
-
new_list_id = list_id + list_id_offset
|
|
1143
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
1144
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
1241
|
+
# Check media query limit
|
|
1242
|
+
unless @media_index.key?(combined_media_sym)
|
|
1243
|
+
@_media_query_count += 1
|
|
1244
|
+
if @_media_query_count > MAX_MEDIA_QUERIES
|
|
1245
|
+
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
1246
|
+
end
|
|
1145
1247
|
end
|
|
1146
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1147
|
-
end
|
|
1148
1248
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
1155
|
-
@media_queries << new_mq
|
|
1156
|
-
end
|
|
1157
|
-
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1158
|
-
end
|
|
1249
|
+
@_pos += 1 # skip '{'
|
|
1250
|
+
|
|
1251
|
+
# Find matching closing brace
|
|
1252
|
+
block_start = @_pos
|
|
1253
|
+
block_end = find_matching_brace(@_pos)
|
|
1159
1254
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
# Offset the list_id and media_query_ids
|
|
1164
|
-
new_list_id = list_id + @_next_media_query_list_id
|
|
1165
|
-
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1166
|
-
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1255
|
+
# Check depth before recursing
|
|
1256
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1257
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1167
1258
|
end
|
|
1168
|
-
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1169
|
-
end
|
|
1170
1259
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1260
|
+
# Parse the content with the combined media context
|
|
1261
|
+
# Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
|
|
1262
|
+
# The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
|
|
1263
|
+
nested_parser = Parser.new(
|
|
1264
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1265
|
+
parser_options: @_parser_options,
|
|
1266
|
+
parent_media_sym: combined_media_sym,
|
|
1267
|
+
depth: @_depth + 1
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
nested_result = nested_parser.parse
|
|
1271
|
+
|
|
1272
|
+
list_id_offset = merge_nested_selector_lists(nested_result)
|
|
1273
|
+
|
|
1274
|
+
# Merge nested MediaQuery objects with offsetted IDs
|
|
1275
|
+
mq_id_offset = @_media_query_id_counter
|
|
1276
|
+
if nested_result[:media_queries] && !nested_result[:media_queries].empty?
|
|
1277
|
+
nested_result[:media_queries].each do |mq|
|
|
1278
|
+
# Create new MediaQuery with offsetted ID
|
|
1279
|
+
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
1280
|
+
@media_queries << new_mq
|
|
1281
|
+
end
|
|
1282
|
+
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1283
|
+
end
|
|
1174
1284
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1285
|
+
# Merge nested media_query_lists with offsetted IDs
|
|
1286
|
+
if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
|
|
1287
|
+
nested_result[:_media_query_lists].each do |list_id, mq_ids|
|
|
1288
|
+
# Offset the list_id and media_query_ids
|
|
1289
|
+
new_list_id = list_id + @_next_media_query_list_id
|
|
1290
|
+
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1291
|
+
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1292
|
+
end
|
|
1293
|
+
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1181
1294
|
end
|
|
1182
1295
|
|
|
1183
|
-
#
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
@media_queries
|
|
1197
|
-
|
|
1198
|
-
|
|
1296
|
+
# Merge nested media_index into ours (for nested @media)
|
|
1297
|
+
# Note: We no longer build media_index during parse
|
|
1298
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1299
|
+
|
|
1300
|
+
merge_nested_rules(nested_result, list_id_offset) do |rule|
|
|
1301
|
+
# Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
|
|
1302
|
+
if rule.media_query_id
|
|
1303
|
+
# Nested parser assigned a media_query_id - need to combine with our context
|
|
1304
|
+
nested_mq_id = rule.media_query_id + mq_id_offset
|
|
1305
|
+
nested_mq = @media_queries[nested_mq_id]
|
|
1306
|
+
|
|
1307
|
+
# Combine nested media query with our media context
|
|
1308
|
+
if nested_mq && combined_media_query_id
|
|
1309
|
+
outer_mq = @media_queries[combined_media_query_id]
|
|
1310
|
+
if outer_mq
|
|
1311
|
+
# Combine media queries directly without string building
|
|
1312
|
+
combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
|
|
1313
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
1314
|
+
@media_queries << combined_mq
|
|
1315
|
+
rule.media_query_id = @_media_query_id_counter
|
|
1316
|
+
@_media_query_id_counter += 1
|
|
1317
|
+
else
|
|
1318
|
+
rule.media_query_id = nested_mq_id
|
|
1319
|
+
end
|
|
1199
1320
|
else
|
|
1200
1321
|
rule.media_query_id = nested_mq_id
|
|
1201
1322
|
end
|
|
1202
|
-
|
|
1203
|
-
|
|
1323
|
+
elsif rule.respond_to?(:media_query_id=)
|
|
1324
|
+
# Assign the combined media_query_id if no media_query_id set
|
|
1325
|
+
# (applies to both Rule and AtRule)
|
|
1326
|
+
rule.media_query_id = combined_media_query_id
|
|
1204
1327
|
end
|
|
1205
|
-
elsif rule.respond_to?(:media_query_id=)
|
|
1206
|
-
# Assign the combined media_query_id if no media_query_id set
|
|
1207
|
-
# (applies to both Rule and AtRule)
|
|
1208
|
-
rule.media_query_id = combined_media_query_id
|
|
1209
1328
|
end
|
|
1210
1329
|
|
|
1211
|
-
#
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
@_rule_id_counter += 1
|
|
1215
|
-
@rules << rule
|
|
1330
|
+
# Move position past the closing brace
|
|
1331
|
+
@_pos = block_end
|
|
1332
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1216
1333
|
end
|
|
1217
1334
|
|
|
1218
|
-
#
|
|
1219
|
-
|
|
1220
|
-
@
|
|
1335
|
+
# @keyframes (contains a <rule-list> of percentage/from/to blocks)
|
|
1336
|
+
#
|
|
1337
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1338
|
+
def parse_keyframes_at_rule(at_rule_start)
|
|
1339
|
+
# Build full selector string: "@keyframes fade"
|
|
1340
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1341
|
+
return if selector.nil?
|
|
1221
1342
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1343
|
+
# Find matching closing brace
|
|
1344
|
+
block_start = @_pos
|
|
1345
|
+
block_end = find_matching_brace(@_pos)
|
|
1224
1346
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1347
|
+
# Check depth before recursing
|
|
1348
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1349
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1350
|
+
end
|
|
1229
1351
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1352
|
+
# Parse keyframe blocks as rules (0%/from/to etc)
|
|
1353
|
+
# Create a nested parser context
|
|
1354
|
+
nested_parser = Parser.new(
|
|
1355
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1356
|
+
parser_options: @_parser_options,
|
|
1357
|
+
depth: @_depth + 1
|
|
1358
|
+
)
|
|
1359
|
+
nested_result = nested_parser.parse
|
|
1360
|
+
content = nested_result[:rules]
|
|
1233
1361
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1362
|
+
# Move position past the closing brace
|
|
1363
|
+
@_pos = block_end
|
|
1364
|
+
# The closing brace should be at block_end
|
|
1365
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1238
1366
|
|
|
1239
|
-
|
|
1367
|
+
# Get rule ID and increment
|
|
1368
|
+
rule_id = @_rule_id_counter
|
|
1369
|
+
@_rule_id_counter += 1
|
|
1240
1370
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
selector_end -= 1
|
|
1371
|
+
# Create AtRule with nested rules
|
|
1372
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1373
|
+
@rules << at_rule
|
|
1245
1374
|
end
|
|
1246
|
-
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1247
1375
|
|
|
1248
|
-
@
|
|
1376
|
+
# @font-face (contains a <declaration-list>)
|
|
1377
|
+
#
|
|
1378
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1379
|
+
def parse_font_face_at_rule(at_rule_start)
|
|
1380
|
+
# Build selector string: "@font-face"
|
|
1381
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1382
|
+
return if selector.nil?
|
|
1249
1383
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1384
|
+
# Find matching closing brace
|
|
1385
|
+
decl_start = @_pos
|
|
1386
|
+
decl_end = find_matching_brace(@_pos)
|
|
1253
1387
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1257
|
-
end
|
|
1388
|
+
# Parse declarations
|
|
1389
|
+
content = parse_declarations_block(decl_start, decl_end)
|
|
1258
1390
|
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
parser_options: @_parser_options,
|
|
1264
|
-
depth: @_depth + 1
|
|
1265
|
-
)
|
|
1266
|
-
nested_result = nested_parser.parse
|
|
1267
|
-
content = nested_result[:rules]
|
|
1268
|
-
|
|
1269
|
-
# Move position past the closing brace
|
|
1270
|
-
@_pos = block_end
|
|
1271
|
-
# The closing brace should be at block_end
|
|
1272
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1273
|
-
|
|
1274
|
-
# Get rule ID and increment
|
|
1275
|
-
rule_id = @_rule_id_counter
|
|
1276
|
-
@_rule_id_counter += 1
|
|
1277
|
-
|
|
1278
|
-
# Create AtRule with nested rules
|
|
1279
|
-
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1280
|
-
@rules << at_rule
|
|
1281
|
-
|
|
1282
|
-
return
|
|
1283
|
-
end
|
|
1391
|
+
# Move position past the closing brace
|
|
1392
|
+
@_pos = decl_end
|
|
1393
|
+
# The closing brace should be at decl_end
|
|
1394
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1284
1395
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
selector_start = at_rule_start # Points to '@'
|
|
1396
|
+
# Get rule ID and increment
|
|
1397
|
+
rule_id = @_rule_id_counter
|
|
1398
|
+
@_rule_id_counter += 1
|
|
1289
1399
|
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
@
|
|
1400
|
+
# Create AtRule with declarations
|
|
1401
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1402
|
+
@rules << at_rule
|
|
1293
1403
|
end
|
|
1294
1404
|
|
|
1295
|
-
|
|
1405
|
+
# Unknown at-rule (@property, @page, @counter-style, etc.) - treated as a
|
|
1406
|
+
# regular selector-based rule with declarations, since this parser has no
|
|
1407
|
+
# special handling for it.
|
|
1408
|
+
#
|
|
1409
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1410
|
+
def parse_unknown_at_rule(at_rule_start)
|
|
1411
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1412
|
+
return if selector.nil?
|
|
1296
1413
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1300
|
-
selector_end -= 1
|
|
1301
|
-
end
|
|
1302
|
-
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1414
|
+
# Parse declarations
|
|
1415
|
+
declarations = parse_declarations
|
|
1303
1416
|
|
|
1304
|
-
|
|
1417
|
+
# Create Rule with declarations
|
|
1418
|
+
rule = Rule.new(
|
|
1419
|
+
@_rule_id_counter, # id
|
|
1420
|
+
selector, # selector (e.g., "@property --main-color")
|
|
1421
|
+
declarations, # declarations
|
|
1422
|
+
nil, # specificity
|
|
1423
|
+
nil, # parent_rule_id
|
|
1424
|
+
nil # nesting_style
|
|
1425
|
+
)
|
|
1305
1426
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1427
|
+
@rules << rule
|
|
1428
|
+
@_rule_id_counter += 1
|
|
1429
|
+
end
|
|
1309
1430
|
|
|
1310
|
-
#
|
|
1311
|
-
|
|
1431
|
+
# Check if block contains nested selectors vs just declarations
|
|
1432
|
+
# Translated from C: see ext/cataract/css_parser.c has_nested_selectors
|
|
1433
|
+
def has_nested_selectors?(start_pos, end_pos)
|
|
1434
|
+
pos = start_pos
|
|
1312
1435
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1436
|
+
while pos < end_pos
|
|
1437
|
+
# Skip whitespace
|
|
1438
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1439
|
+
pos += 1
|
|
1440
|
+
end
|
|
1441
|
+
break if pos >= end_pos
|
|
1317
1442
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1443
|
+
# Skip comments
|
|
1444
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
1445
|
+
pos += 2
|
|
1446
|
+
while pos + 1 < end_pos
|
|
1447
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
1448
|
+
pos += 2
|
|
1449
|
+
break
|
|
1450
|
+
end
|
|
1451
|
+
pos += 1
|
|
1452
|
+
end
|
|
1453
|
+
next
|
|
1454
|
+
end
|
|
1321
1455
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1456
|
+
# Check for nested selector indicators
|
|
1457
|
+
byte = @_css.getbyte(pos)
|
|
1458
|
+
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
1459
|
+
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
1460
|
+
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
|
|
1461
|
+
# Look ahead - if followed by {, it's likely a nested selector
|
|
1462
|
+
lookahead = pos + 1
|
|
1463
|
+
while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
|
|
1464
|
+
@_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
|
|
1465
|
+
lookahead += 1
|
|
1466
|
+
end
|
|
1467
|
+
return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
|
|
1468
|
+
end
|
|
1325
1469
|
|
|
1326
|
-
|
|
1327
|
-
|
|
1470
|
+
# Check for @media, @supports, etc nested inside
|
|
1471
|
+
return true if byte == BYTE_AT
|
|
1328
1472
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1473
|
+
# Skip to next line or semicolon
|
|
1474
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
|
|
1475
|
+
pos += 1
|
|
1476
|
+
end
|
|
1477
|
+
pos += 1 if pos < end_pos
|
|
1478
|
+
end
|
|
1332
1479
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1480
|
+
false
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
# Resolve nested selector against parent
|
|
1484
|
+
# Translated from C: see ext/cataract/css_parser.c resolve_nested_selector
|
|
1485
|
+
# Examples:
|
|
1486
|
+
# resolve_nested_selector(".parent", "& .child") => [".parent .child", 1] (explicit)
|
|
1487
|
+
# resolve_nested_selector(".parent", "&:hover") => [".parent:hover", 1] (explicit)
|
|
1488
|
+
# resolve_nested_selector(".parent", "&.active") => [".parent.active", 1] (explicit)
|
|
1489
|
+
# resolve_nested_selector(".parent", ".child") => [".parent .child", 0] (implicit)
|
|
1490
|
+
# resolve_nested_selector(".parent", "> .child") => [".parent > .child", 0] (implicit combinator)
|
|
1491
|
+
#
|
|
1492
|
+
# Returns: [resolved_selector, nesting_style]
|
|
1493
|
+
# nesting_style: 0 = NESTING_STYLE_IMPLICIT, 1 = NESTING_STYLE_EXPLICIT
|
|
1494
|
+
def resolve_nested_selector(parent_selector, nested_selector)
|
|
1495
|
+
# Check if nested selector contains & (byte-level search)
|
|
1496
|
+
len = nested_selector.bytesize
|
|
1497
|
+
has_ampersand = false
|
|
1498
|
+
i = 0
|
|
1499
|
+
while i < len
|
|
1500
|
+
if nested_selector.getbyte(i) == BYTE_AMPERSAND
|
|
1501
|
+
has_ampersand = true
|
|
1502
|
+
break
|
|
1503
|
+
end
|
|
1504
|
+
i += 1
|
|
1505
|
+
end
|
|
1337
1506
|
|
|
1338
|
-
|
|
1507
|
+
if has_ampersand
|
|
1508
|
+
# Explicit nesting - replace & with parent
|
|
1509
|
+
nesting_style = NESTING_STYLE_EXPLICIT
|
|
1510
|
+
|
|
1511
|
+
# Trim leading whitespace to check for combinator
|
|
1512
|
+
# NOTE: We use a manual byte-level loop instead of lstrip for performance.
|
|
1513
|
+
# Ruby's lstrip handles all Unicode whitespace and encoding checks, but CSS
|
|
1514
|
+
# selectors only use ASCII whitespace (space, tab, newline, CR). Our loop
|
|
1515
|
+
# checks only these 4 bytes, which benchmarks 1.89x faster than lstrip.
|
|
1516
|
+
start_pos = 0
|
|
1517
|
+
while start_pos < len
|
|
1518
|
+
byte = nested_selector.getbyte(start_pos)
|
|
1519
|
+
break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
1520
|
+
|
|
1521
|
+
start_pos += 1
|
|
1522
|
+
end
|
|
1339
1523
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1524
|
+
# Check if selector starts with a combinator (relative selector)
|
|
1525
|
+
starts_with_combinator = false
|
|
1526
|
+
if start_pos < len
|
|
1527
|
+
first_byte = nested_selector.getbyte(start_pos)
|
|
1528
|
+
starts_with_combinator = (first_byte == BYTE_PLUS || first_byte == BYTE_GT || first_byte == BYTE_TILDE)
|
|
1529
|
+
end
|
|
1346
1530
|
|
|
1347
|
-
|
|
1531
|
+
# Build result by replacing & with parent
|
|
1532
|
+
result = String.new
|
|
1533
|
+
if starts_with_combinator
|
|
1534
|
+
# Prepend parent first with space for relative selectors
|
|
1535
|
+
# Example: "+ .bar + &" => ".foo + .bar + .foo"
|
|
1536
|
+
result << parent_selector
|
|
1537
|
+
result << ' '
|
|
1538
|
+
end
|
|
1348
1539
|
|
|
1349
|
-
|
|
1350
|
-
|
|
1540
|
+
# Replace all & with parent selector (byte-level iteration)
|
|
1541
|
+
i = 0
|
|
1542
|
+
while i < len
|
|
1543
|
+
byte = nested_selector.getbyte(i)
|
|
1544
|
+
result << if byte == BYTE_AMPERSAND
|
|
1545
|
+
parent_selector
|
|
1546
|
+
else
|
|
1547
|
+
byte.chr
|
|
1548
|
+
end
|
|
1549
|
+
i += 1
|
|
1550
|
+
end
|
|
1351
1551
|
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
declarations, # declarations
|
|
1357
|
-
nil, # specificity
|
|
1358
|
-
nil, # parent_rule_id
|
|
1359
|
-
nil # nesting_style
|
|
1360
|
-
)
|
|
1552
|
+
[result, nesting_style]
|
|
1553
|
+
else
|
|
1554
|
+
# Implicit nesting - prepend parent with appropriate spacing
|
|
1555
|
+
nesting_style = NESTING_STYLE_IMPLICIT
|
|
1361
1556
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1557
|
+
# Trim leading whitespace from nested selector (byte-level)
|
|
1558
|
+
# See comment above for why we don't use lstrip
|
|
1559
|
+
start_pos = 0
|
|
1560
|
+
while start_pos < len
|
|
1561
|
+
byte = nested_selector.getbyte(start_pos)
|
|
1562
|
+
break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
1365
1563
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
def has_nested_selectors?(start_pos, end_pos)
|
|
1369
|
-
pos = start_pos
|
|
1564
|
+
start_pos += 1
|
|
1565
|
+
end
|
|
1370
1566
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
end
|
|
1376
|
-
break if pos >= end_pos
|
|
1567
|
+
result = String.new
|
|
1568
|
+
result << parent_selector
|
|
1569
|
+
result << ' '
|
|
1570
|
+
result << nested_selector.byteslice(start_pos, nested_selector.bytesize - start_pos)
|
|
1377
1571
|
|
|
1378
|
-
|
|
1379
|
-
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
1380
|
-
pos += 2
|
|
1381
|
-
while pos + 1 < end_pos
|
|
1382
|
-
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
1383
|
-
pos += 2
|
|
1384
|
-
break
|
|
1385
|
-
end
|
|
1386
|
-
pos += 1
|
|
1572
|
+
[result, nesting_style]
|
|
1387
1573
|
end
|
|
1388
|
-
next
|
|
1389
1574
|
end
|
|
1390
1575
|
|
|
1391
|
-
#
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1576
|
+
# Combine parent and child media queries
|
|
1577
|
+
# Translated from C: see ext/cataract/css_parser.c combine_media_queries
|
|
1578
|
+
# Examples:
|
|
1579
|
+
# parent="screen", child="min-width: 500px" => "screen and (min-width: 500px)"
|
|
1580
|
+
# parent=nil, child="print" => "print"
|
|
1581
|
+
def combine_media_queries(parent, child)
|
|
1582
|
+
return child if parent.nil?
|
|
1583
|
+
return parent if child.nil?
|
|
1584
|
+
|
|
1585
|
+
# Combine: "parent and child"
|
|
1586
|
+
parent_str = parent.to_s
|
|
1587
|
+
child_str = child.to_s
|
|
1588
|
+
|
|
1589
|
+
combined = "#{parent_str} and "
|
|
1590
|
+
|
|
1591
|
+
# If child is a condition (contains ':'), wrap it in parentheses
|
|
1592
|
+
combined += if child_str.include?(':')
|
|
1593
|
+
# Add parens if not already present
|
|
1594
|
+
len = child_str.bytesize
|
|
1595
|
+
if len > 1 && child_str.getbyte(0) == BYTE_LPAREN && child_str.getbyte(len - 1) == BYTE_RPAREN
|
|
1596
|
+
child_str
|
|
1597
|
+
else
|
|
1598
|
+
"(#{child_str})"
|
|
1599
|
+
end
|
|
1600
|
+
else
|
|
1601
|
+
child_str
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
combined.to_sym
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
# Skip to next semicolon or closing brace (error recovery)
|
|
1608
|
+
def skip_to_semicolon_or_brace
|
|
1609
|
+
# rubocop:disable Layout/LineLength
|
|
1610
|
+
until eof? || peek_byte == BYTE_SEMICOLON || peek_byte == BYTE_RBRACE # Flip to save a not_opt instruction: while !eof? && peek_byte != BYTE_SEMICOLON && peek_byte != BYTE_RBRACE
|
|
1611
|
+
# rubocop:enable Layout/LineLength
|
|
1612
|
+
@_pos += 1
|
|
1401
1613
|
end
|
|
1402
|
-
|
|
1614
|
+
|
|
1615
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1403
1616
|
end
|
|
1404
1617
|
|
|
1405
|
-
#
|
|
1406
|
-
|
|
1618
|
+
# Parse an @import statement
|
|
1619
|
+
# @import "url" [media-query];
|
|
1620
|
+
# @import url("url") [media-query];
|
|
1621
|
+
def parse_import_statement
|
|
1622
|
+
skip_ws_and_comments
|
|
1407
1623
|
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1624
|
+
# Check for optional url(
|
|
1625
|
+
has_url_function = false
|
|
1626
|
+
if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
|
|
1627
|
+
has_url_function = true
|
|
1628
|
+
@_pos += 4
|
|
1629
|
+
skip_ws_and_comments
|
|
1630
|
+
end
|
|
1414
1631
|
|
|
1415
|
-
|
|
1416
|
-
|
|
1632
|
+
# Find opening quote
|
|
1633
|
+
byte = peek_byte
|
|
1634
|
+
if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
|
|
1635
|
+
# Invalid @import, skip to semicolon
|
|
1636
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1637
|
+
@_pos += 1
|
|
1638
|
+
end
|
|
1639
|
+
@_pos += 1 unless eof?
|
|
1640
|
+
return
|
|
1641
|
+
end
|
|
1417
1642
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
# Examples:
|
|
1421
|
-
# resolve_nested_selector(".parent", "& .child") => [".parent .child", 1] (explicit)
|
|
1422
|
-
# resolve_nested_selector(".parent", "&:hover") => [".parent:hover", 1] (explicit)
|
|
1423
|
-
# resolve_nested_selector(".parent", "&.active") => [".parent.active", 1] (explicit)
|
|
1424
|
-
# resolve_nested_selector(".parent", ".child") => [".parent .child", 0] (implicit)
|
|
1425
|
-
# resolve_nested_selector(".parent", "> .child") => [".parent > .child", 0] (implicit combinator)
|
|
1426
|
-
#
|
|
1427
|
-
# Returns: [resolved_selector, nesting_style]
|
|
1428
|
-
# nesting_style: 0 = NESTING_STYLE_IMPLICIT, 1 = NESTING_STYLE_EXPLICIT
|
|
1429
|
-
def resolve_nested_selector(parent_selector, nested_selector)
|
|
1430
|
-
# Check if nested selector contains & (byte-level search)
|
|
1431
|
-
len = nested_selector.bytesize
|
|
1432
|
-
has_ampersand = false
|
|
1433
|
-
i = 0
|
|
1434
|
-
while i < len
|
|
1435
|
-
if nested_selector.getbyte(i) == BYTE_AMPERSAND
|
|
1436
|
-
has_ampersand = true
|
|
1437
|
-
break
|
|
1438
|
-
end
|
|
1439
|
-
i += 1
|
|
1440
|
-
end
|
|
1643
|
+
quote_char = byte
|
|
1644
|
+
@_pos += 1 # Skip opening quote
|
|
1441
1645
|
|
|
1442
|
-
|
|
1443
|
-
# Explicit nesting - replace & with parent
|
|
1444
|
-
nesting_style = NESTING_STYLE_EXPLICIT
|
|
1445
|
-
|
|
1446
|
-
# Trim leading whitespace to check for combinator
|
|
1447
|
-
# NOTE: We use a manual byte-level loop instead of lstrip for performance.
|
|
1448
|
-
# Ruby's lstrip handles all Unicode whitespace and encoding checks, but CSS
|
|
1449
|
-
# selectors only use ASCII whitespace (space, tab, newline, CR). Our loop
|
|
1450
|
-
# checks only these 4 bytes, which benchmarks 1.89x faster than lstrip.
|
|
1451
|
-
start_pos = 0
|
|
1452
|
-
while start_pos < len
|
|
1453
|
-
byte = nested_selector.getbyte(start_pos)
|
|
1454
|
-
break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
1455
|
-
|
|
1456
|
-
start_pos += 1
|
|
1457
|
-
end
|
|
1646
|
+
url_start = @_pos
|
|
1458
1647
|
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1648
|
+
# Find closing quote (handle escaped quotes)
|
|
1649
|
+
while !eof? && peek_byte != quote_char
|
|
1650
|
+
@_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
1651
|
+
2 # Skip escaped character
|
|
1652
|
+
else
|
|
1653
|
+
1
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1465
1656
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
# Example: "+ .bar + &" => ".foo + .bar + .foo"
|
|
1471
|
-
result << parent_selector
|
|
1472
|
-
result << ' '
|
|
1473
|
-
end
|
|
1657
|
+
if eof?
|
|
1658
|
+
# Unterminated string
|
|
1659
|
+
return
|
|
1660
|
+
end
|
|
1474
1661
|
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
while i < len
|
|
1478
|
-
byte = nested_selector.getbyte(i)
|
|
1479
|
-
result << if byte == BYTE_AMPERSAND
|
|
1480
|
-
parent_selector
|
|
1481
|
-
else
|
|
1482
|
-
byte.chr
|
|
1483
|
-
end
|
|
1484
|
-
i += 1
|
|
1485
|
-
end
|
|
1662
|
+
url = byteslice_encoded(url_start, @_pos - url_start)
|
|
1663
|
+
@_pos += 1 # Skip closing quote
|
|
1486
1664
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1665
|
+
# Skip closing paren if we had url(
|
|
1666
|
+
if has_url_function
|
|
1667
|
+
skip_ws_and_comments
|
|
1668
|
+
@_pos += 1 if peek_byte == BYTE_RPAREN
|
|
1669
|
+
end
|
|
1491
1670
|
|
|
1492
|
-
|
|
1493
|
-
# See comment above for why we don't use lstrip
|
|
1494
|
-
start_pos = 0
|
|
1495
|
-
while start_pos < len
|
|
1496
|
-
byte = nested_selector.getbyte(start_pos)
|
|
1497
|
-
break unless byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
1671
|
+
skip_ws_and_comments
|
|
1498
1672
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1673
|
+
# Check for optional media query (everything until semicolon)
|
|
1674
|
+
media_string = nil
|
|
1675
|
+
media_query_id = nil
|
|
1676
|
+
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1677
|
+
media_start = @_pos
|
|
1501
1678
|
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1679
|
+
# Find semicolon
|
|
1680
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1681
|
+
@_pos += 1
|
|
1682
|
+
end
|
|
1506
1683
|
|
|
1507
|
-
|
|
1508
|
-
end
|
|
1509
|
-
end
|
|
1684
|
+
media_end = @_pos
|
|
1510
1685
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
# parent=nil, child="print" => "print"
|
|
1516
|
-
def combine_media_queries(parent, child)
|
|
1517
|
-
return child if parent.nil?
|
|
1518
|
-
return parent if child.nil?
|
|
1519
|
-
|
|
1520
|
-
# Combine: "parent and child"
|
|
1521
|
-
parent_str = parent.to_s
|
|
1522
|
-
child_str = child.to_s
|
|
1523
|
-
|
|
1524
|
-
combined = "#{parent_str} and "
|
|
1525
|
-
|
|
1526
|
-
# If child is a condition (contains ':'), wrap it in parentheses
|
|
1527
|
-
combined += if child_str.include?(':')
|
|
1528
|
-
# Add parens if not already present
|
|
1529
|
-
len = child_str.bytesize
|
|
1530
|
-
if len > 1 && child_str.getbyte(0) == BYTE_LPAREN && child_str.getbyte(len - 1) == BYTE_RPAREN
|
|
1531
|
-
child_str
|
|
1532
|
-
else
|
|
1533
|
-
"(#{child_str})"
|
|
1534
|
-
end
|
|
1535
|
-
else
|
|
1536
|
-
child_str
|
|
1537
|
-
end
|
|
1686
|
+
# Trim trailing whitespace from media query
|
|
1687
|
+
while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
|
|
1688
|
+
media_end -= 1
|
|
1689
|
+
end
|
|
1538
1690
|
|
|
1539
|
-
|
|
1540
|
-
|
|
1691
|
+
if media_end > media_start
|
|
1692
|
+
media_string = byteslice_encoded(media_start, media_end - media_start)
|
|
1693
|
+
|
|
1694
|
+
# Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
|
|
1695
|
+
media_query_strings = media_string.split(',').map(&:strip)
|
|
1696
|
+
|
|
1697
|
+
# Create MediaQuery objects for each query in the list
|
|
1698
|
+
media_query_ids = []
|
|
1699
|
+
media_query_strings.each do |query_string|
|
|
1700
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1701
|
+
|
|
1702
|
+
# If we have a parent import's media context, combine them
|
|
1703
|
+
parent_import_type = @_parser_options[:parent_import_media_type]
|
|
1704
|
+
parent_import_conditions = @_parser_options[:parent_import_media_conditions]
|
|
1705
|
+
|
|
1706
|
+
if parent_import_type
|
|
1707
|
+
# Combine: parent's type is the effective type
|
|
1708
|
+
# Conditions are combined with "and"
|
|
1709
|
+
combined_type = parent_import_type
|
|
1710
|
+
combined_conditions = if parent_import_conditions && media_conditions
|
|
1711
|
+
"#{parent_import_conditions} and #{media_conditions}"
|
|
1712
|
+
elsif parent_import_conditions
|
|
1713
|
+
"#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
|
|
1714
|
+
elsif media_conditions
|
|
1715
|
+
media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
|
|
1716
|
+
else
|
|
1717
|
+
media_type == parent_import_type ? nil : media_type.to_s
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
media_type = combined_type
|
|
1721
|
+
media_conditions = combined_conditions
|
|
1722
|
+
end
|
|
1723
|
+
|
|
1724
|
+
# Create MediaQuery object
|
|
1725
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1726
|
+
@media_queries << media_query
|
|
1727
|
+
media_query_ids << @_media_query_id_counter
|
|
1728
|
+
@_media_query_id_counter += 1
|
|
1729
|
+
end
|
|
1541
1730
|
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
@_pos += 1
|
|
1546
|
-
end
|
|
1731
|
+
# Use the first media query ID for the import statement
|
|
1732
|
+
# (The list is tracked separately for serialization)
|
|
1733
|
+
media_query_id = media_query_ids.first
|
|
1547
1734
|
|
|
1548
|
-
|
|
1549
|
-
|
|
1735
|
+
# If multiple queries, track them as a list for serialization
|
|
1736
|
+
if media_query_ids.size > 1
|
|
1737
|
+
media_query_list_id = @_next_media_query_list_id
|
|
1738
|
+
@_media_query_lists[media_query_list_id] = media_query_ids
|
|
1739
|
+
@_next_media_query_list_id += 1
|
|
1740
|
+
end
|
|
1741
|
+
end
|
|
1742
|
+
end
|
|
1550
1743
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
# @import url("url") [media-query];
|
|
1554
|
-
def parse_import_statement
|
|
1555
|
-
skip_ws_and_comments
|
|
1556
|
-
|
|
1557
|
-
# Check for optional url(
|
|
1558
|
-
has_url_function = false
|
|
1559
|
-
if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
|
|
1560
|
-
has_url_function = true
|
|
1561
|
-
@_pos += 4
|
|
1562
|
-
skip_ws_and_comments
|
|
1563
|
-
end
|
|
1744
|
+
# Skip semicolon
|
|
1745
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1564
1746
|
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1570
|
-
@_pos += 1
|
|
1747
|
+
# Create ImportStatement (resolved: false by default)
|
|
1748
|
+
import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
|
|
1749
|
+
@imports << import_stmt
|
|
1750
|
+
@_rule_id_counter += 1
|
|
1571
1751
|
end
|
|
1572
|
-
@_pos += 1 unless eof?
|
|
1573
|
-
return
|
|
1574
|
-
end
|
|
1575
1752
|
|
|
1576
|
-
|
|
1577
|
-
|
|
1753
|
+
# Convert relative URLs in a value string to absolute URLs
|
|
1754
|
+
# Called when @_absolute_paths is enabled and @_base_uri is set
|
|
1755
|
+
#
|
|
1756
|
+
# @param value [String] The declaration value to process
|
|
1757
|
+
# @return [String] Value with relative URLs converted to absolute
|
|
1758
|
+
def convert_urls_in_value(value)
|
|
1759
|
+
return value unless @_absolute_paths && @_base_uri
|
|
1760
|
+
|
|
1761
|
+
result = +''
|
|
1762
|
+
pos = 0
|
|
1763
|
+
len = value.bytesize
|
|
1764
|
+
|
|
1765
|
+
while pos < len
|
|
1766
|
+
# Look for 'url(' - case insensitive
|
|
1767
|
+
byte = value.getbyte(pos)
|
|
1768
|
+
if pos + 3 < len &&
|
|
1769
|
+
(byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
|
|
1770
|
+
(value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
|
|
1771
|
+
(value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
|
|
1772
|
+
value.getbyte(pos + 3) == BYTE_LPAREN
|
|
1773
|
+
|
|
1774
|
+
result << value.byteslice(pos, 4) # append 'url('
|
|
1775
|
+
pos += 4
|
|
1776
|
+
|
|
1777
|
+
# Skip whitespace
|
|
1778
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1779
|
+
result << value.getbyte(pos).chr
|
|
1780
|
+
pos += 1
|
|
1781
|
+
end
|
|
1578
1782
|
|
|
1579
|
-
|
|
1783
|
+
# Check for quote
|
|
1784
|
+
quote_char = nil
|
|
1785
|
+
if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
|
|
1786
|
+
quote_char = value.getbyte(pos)
|
|
1787
|
+
pos += 1
|
|
1788
|
+
end
|
|
1580
1789
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1790
|
+
# Extract URL
|
|
1791
|
+
url_start = pos
|
|
1792
|
+
if quote_char
|
|
1793
|
+
# Scan until matching quote
|
|
1794
|
+
while pos < len && value.getbyte(pos) != quote_char
|
|
1795
|
+
# Handle escape
|
|
1796
|
+
pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
|
|
1797
|
+
2
|
|
1798
|
+
else
|
|
1799
|
+
1
|
|
1800
|
+
end
|
|
1801
|
+
end
|
|
1802
|
+
else
|
|
1803
|
+
# Scan until ) or whitespace
|
|
1804
|
+
while pos < len
|
|
1805
|
+
b = value.getbyte(pos)
|
|
1806
|
+
break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
|
|
1589
1807
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
end
|
|
1808
|
+
pos += 1
|
|
1809
|
+
end
|
|
1810
|
+
end
|
|
1594
1811
|
|
|
1595
|
-
|
|
1596
|
-
@_pos += 1 # Skip closing quote
|
|
1812
|
+
url_str = value.byteslice(url_start, pos - url_start)
|
|
1597
1813
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1814
|
+
# Check if URL needs resolution (is relative)
|
|
1815
|
+
# Skip if: contains "://" OR starts with "data:"
|
|
1816
|
+
needs_resolution = true
|
|
1817
|
+
if url_str.empty?
|
|
1818
|
+
needs_resolution = false
|
|
1819
|
+
else
|
|
1820
|
+
# Check for "://"
|
|
1821
|
+
i = 0
|
|
1822
|
+
url_len = url_str.bytesize
|
|
1823
|
+
while i + 2 < url_len
|
|
1824
|
+
if url_str.getbyte(i) == BYTE_COLON &&
|
|
1825
|
+
url_str.getbyte(i + 1) == BYTE_SLASH &&
|
|
1826
|
+
url_str.getbyte(i + 2) == BYTE_SLASH
|
|
1827
|
+
needs_resolution = false
|
|
1828
|
+
break
|
|
1829
|
+
end
|
|
1830
|
+
i += 1
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# Check for "data:" prefix (case insensitive)
|
|
1834
|
+
if needs_resolution && url_len >= 5
|
|
1835
|
+
if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
|
|
1836
|
+
(url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
|
|
1837
|
+
(url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
|
|
1838
|
+
(url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
|
|
1839
|
+
url_str.getbyte(4) == BYTE_COLON
|
|
1840
|
+
needs_resolution = false
|
|
1841
|
+
end
|
|
1842
|
+
end
|
|
1843
|
+
end
|
|
1603
1844
|
|
|
1604
|
-
|
|
1845
|
+
if needs_resolution
|
|
1846
|
+
# Resolve relative URL using the resolver proc
|
|
1847
|
+
begin
|
|
1848
|
+
resolved = @_uri_resolver.call(@_base_uri, url_str)
|
|
1849
|
+
result << "'" << resolved << "'"
|
|
1850
|
+
rescue StandardError
|
|
1851
|
+
# If resolution fails, preserve original
|
|
1852
|
+
if quote_char
|
|
1853
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1854
|
+
else
|
|
1855
|
+
result << url_str
|
|
1856
|
+
end
|
|
1857
|
+
end
|
|
1858
|
+
elsif url_str.empty?
|
|
1859
|
+
# Preserve original URL
|
|
1860
|
+
result << "''"
|
|
1861
|
+
elsif quote_char
|
|
1862
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1863
|
+
else
|
|
1864
|
+
result << url_str
|
|
1865
|
+
end
|
|
1605
1866
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
media_query_id = nil
|
|
1609
|
-
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1610
|
-
media_start = @_pos
|
|
1867
|
+
# Skip past closing quote if present
|
|
1868
|
+
pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
|
|
1611
1869
|
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1870
|
+
# Skip whitespace before )
|
|
1871
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1872
|
+
pos += 1
|
|
1873
|
+
end
|
|
1616
1874
|
|
|
1617
|
-
|
|
1875
|
+
# The ) will be copied in the next iteration or at the end
|
|
1876
|
+
else
|
|
1877
|
+
result << byte.chr
|
|
1878
|
+
pos += 1
|
|
1879
|
+
end
|
|
1880
|
+
end
|
|
1618
1881
|
|
|
1619
|
-
|
|
1620
|
-
while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
|
|
1621
|
-
media_end -= 1
|
|
1882
|
+
result
|
|
1622
1883
|
end
|
|
1623
1884
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1885
|
+
# Parse a block of declarations given start/end positions
|
|
1886
|
+
# Used for @font-face and other at-rules
|
|
1887
|
+
# Translated from C: see ext/cataract/css_parser.c parse_declarations
|
|
1888
|
+
def parse_declarations_block(start_pos, end_pos)
|
|
1889
|
+
declarations = []
|
|
1890
|
+
pos = start_pos
|
|
1629
1891
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
# If we have a parent import's media context, combine them
|
|
1636
|
-
parent_import_type = @_parser_options[:parent_import_media_type]
|
|
1637
|
-
parent_import_conditions = @_parser_options[:parent_import_media_conditions]
|
|
1638
|
-
|
|
1639
|
-
if parent_import_type
|
|
1640
|
-
# Combine: parent's type is the effective type
|
|
1641
|
-
# Conditions are combined with "and"
|
|
1642
|
-
combined_type = parent_import_type
|
|
1643
|
-
combined_conditions = if parent_import_conditions && media_conditions
|
|
1644
|
-
"#{parent_import_conditions} and #{media_conditions}"
|
|
1645
|
-
elsif parent_import_conditions
|
|
1646
|
-
"#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
|
|
1647
|
-
elsif media_conditions
|
|
1648
|
-
media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
|
|
1649
|
-
else
|
|
1650
|
-
media_type == parent_import_type ? nil : media_type.to_s
|
|
1651
|
-
end
|
|
1652
|
-
|
|
1653
|
-
media_type = combined_type
|
|
1654
|
-
media_conditions = combined_conditions
|
|
1892
|
+
while pos < end_pos
|
|
1893
|
+
# Skip whitespace
|
|
1894
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1895
|
+
pos += 1
|
|
1655
1896
|
end
|
|
1897
|
+
break if pos >= end_pos
|
|
1656
1898
|
|
|
1657
|
-
#
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
media_query_ids << @_media_query_id_counter
|
|
1661
|
-
@_media_query_id_counter += 1
|
|
1899
|
+
# Parse declaration using shared helper (at-rules don't use !important)
|
|
1900
|
+
decl, pos = parse_single_declaration(pos, end_pos, false)
|
|
1901
|
+
declarations << decl if decl
|
|
1662
1902
|
end
|
|
1663
1903
|
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1904
|
+
declarations
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
# Combine parent and child media query parts directly without string building
|
|
1908
|
+
#
|
|
1909
|
+
# The parent's type takes precedence (child type is ignored per CSS spec).
|
|
1910
|
+
#
|
|
1911
|
+
# @param parent_mq [MediaQuery] Parent media query object
|
|
1912
|
+
# @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
|
|
1913
|
+
# @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
|
|
1914
|
+
#
|
|
1915
|
+
# @example
|
|
1916
|
+
# combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
|
|
1917
|
+
def combine_media_query_parts(parent_mq, child_conditions)
|
|
1918
|
+
# Type: parent's type wins (outermost type)
|
|
1919
|
+
combined_type = parent_mq.type
|
|
1920
|
+
|
|
1921
|
+
# Conditions: combine parent and child conditions
|
|
1922
|
+
combined_conditions = if parent_mq.conditions && child_conditions
|
|
1923
|
+
"#{parent_mq.conditions} and #{child_conditions}"
|
|
1924
|
+
elsif parent_mq.conditions
|
|
1925
|
+
parent_mq.conditions
|
|
1926
|
+
elsif child_conditions
|
|
1927
|
+
child_conditions
|
|
1928
|
+
end
|
|
1667
1929
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1930
|
+
[combined_type, combined_conditions]
|
|
1931
|
+
end
|
|
1932
|
+
|
|
1933
|
+
# Parse media query string into type and conditions
|
|
1934
|
+
#
|
|
1935
|
+
# @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
|
|
1936
|
+
# @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
|
|
1937
|
+
#
|
|
1938
|
+
# @example
|
|
1939
|
+
# parse_media_query_parts("screen") #=> [:screen, nil]
|
|
1940
|
+
# parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
|
|
1941
|
+
# parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
|
|
1942
|
+
def parse_media_query_parts(query)
|
|
1943
|
+
i = 0
|
|
1944
|
+
len = query.bytesize
|
|
1945
|
+
|
|
1946
|
+
# Skip leading whitespace
|
|
1947
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1948
|
+
i += 1
|
|
1673
1949
|
end
|
|
1674
|
-
end
|
|
1675
|
-
end
|
|
1676
1950
|
|
|
1677
|
-
|
|
1678
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1679
|
-
|
|
1680
|
-
# Create ImportStatement (resolved: false by default)
|
|
1681
|
-
import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
|
|
1682
|
-
@imports << import_stmt
|
|
1683
|
-
@_rule_id_counter += 1
|
|
1684
|
-
end
|
|
1951
|
+
return [:all, nil] if i >= len
|
|
1685
1952
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
# @param value [String] The declaration value to process
|
|
1690
|
-
# @return [String] Value with relative URLs converted to absolute
|
|
1691
|
-
def convert_urls_in_value(value)
|
|
1692
|
-
return value unless @_absolute_paths && @_base_uri
|
|
1693
|
-
|
|
1694
|
-
result = +''
|
|
1695
|
-
pos = 0
|
|
1696
|
-
len = value.bytesize
|
|
1697
|
-
|
|
1698
|
-
while pos < len
|
|
1699
|
-
# Look for 'url(' - case insensitive
|
|
1700
|
-
byte = value.getbyte(pos)
|
|
1701
|
-
if pos + 3 < len &&
|
|
1702
|
-
(byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
|
|
1703
|
-
(value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
|
|
1704
|
-
(value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
|
|
1705
|
-
value.getbyte(pos + 3) == BYTE_LPAREN
|
|
1706
|
-
|
|
1707
|
-
result << value.byteslice(pos, 4) # append 'url('
|
|
1708
|
-
pos += 4
|
|
1709
|
-
|
|
1710
|
-
# Skip whitespace
|
|
1711
|
-
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1712
|
-
result << value.getbyte(pos).chr
|
|
1713
|
-
pos += 1
|
|
1953
|
+
# Check if starts with '(' - media feature without type (defaults to :all)
|
|
1954
|
+
if query.getbyte(i) == BYTE_LPAREN
|
|
1955
|
+
return [:all, query.byteslice(i, len - i)]
|
|
1714
1956
|
end
|
|
1715
1957
|
|
|
1716
|
-
#
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1958
|
+
# Find first media type word
|
|
1959
|
+
word_start = i
|
|
1960
|
+
while i < len
|
|
1961
|
+
byte = query.getbyte(i)
|
|
1962
|
+
break if whitespace?(byte) || byte == BYTE_LPAREN
|
|
1963
|
+
|
|
1964
|
+
i += 1
|
|
1721
1965
|
end
|
|
1722
1966
|
|
|
1723
|
-
|
|
1724
|
-
url_start = pos
|
|
1725
|
-
if quote_char
|
|
1726
|
-
# Scan until matching quote
|
|
1727
|
-
while pos < len && value.getbyte(pos) != quote_char
|
|
1728
|
-
# Handle escape
|
|
1729
|
-
pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
|
|
1730
|
-
2
|
|
1731
|
-
else
|
|
1732
|
-
1
|
|
1733
|
-
end
|
|
1734
|
-
end
|
|
1735
|
-
else
|
|
1736
|
-
# Scan until ) or whitespace
|
|
1737
|
-
while pos < len
|
|
1738
|
-
b = value.getbyte(pos)
|
|
1739
|
-
break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
|
|
1967
|
+
type = query.byteslice(word_start, i - word_start).to_sym
|
|
1740
1968
|
|
|
1741
|
-
|
|
1742
|
-
|
|
1969
|
+
# Skip whitespace after type
|
|
1970
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1971
|
+
i += 1
|
|
1743
1972
|
end
|
|
1744
1973
|
|
|
1745
|
-
|
|
1974
|
+
# Check if there's more (conditions)
|
|
1975
|
+
if i >= len
|
|
1976
|
+
return [type, nil]
|
|
1977
|
+
end
|
|
1746
1978
|
|
|
1747
|
-
#
|
|
1748
|
-
#
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1979
|
+
# Look for " and " keyword (case-insensitive)
|
|
1980
|
+
# We need to find "and" as a separate word
|
|
1981
|
+
and_pos = nil
|
|
1982
|
+
check_i = i
|
|
1983
|
+
while check_i < len - 2
|
|
1984
|
+
# Check for 'and' (a=97/65, n=110/78, d=100/68)
|
|
1985
|
+
byte0 = query.getbyte(check_i)
|
|
1986
|
+
byte1 = query.getbyte(check_i + 1)
|
|
1987
|
+
byte2 = query.getbyte(check_i + 2)
|
|
1988
|
+
|
|
1989
|
+
if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
|
|
1990
|
+
(byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
|
|
1991
|
+
(byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
|
|
1992
|
+
# Make sure it's a word boundary (whitespace before and after)
|
|
1993
|
+
before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
|
|
1994
|
+
after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
|
|
1995
|
+
if before_ok && after_ok
|
|
1996
|
+
and_pos = check_i
|
|
1761
1997
|
break
|
|
1762
1998
|
end
|
|
1763
|
-
i += 1
|
|
1764
|
-
end
|
|
1765
|
-
|
|
1766
|
-
# Check for "data:" prefix (case insensitive)
|
|
1767
|
-
if needs_resolution && url_len >= 5
|
|
1768
|
-
if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
|
|
1769
|
-
(url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
|
|
1770
|
-
(url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
|
|
1771
|
-
(url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
|
|
1772
|
-
url_str.getbyte(4) == BYTE_COLON
|
|
1773
|
-
needs_resolution = false
|
|
1774
|
-
end
|
|
1775
1999
|
end
|
|
2000
|
+
check_i += 1
|
|
1776
2001
|
end
|
|
1777
2002
|
|
|
1778
|
-
if
|
|
1779
|
-
#
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
rescue StandardError
|
|
1784
|
-
# If resolution fails, preserve original
|
|
1785
|
-
if quote_char
|
|
1786
|
-
result << quote_char.chr << url_str << quote_char.chr
|
|
1787
|
-
else
|
|
1788
|
-
result << url_str
|
|
1789
|
-
end
|
|
2003
|
+
if and_pos
|
|
2004
|
+
# Skip past "and " to get conditions
|
|
2005
|
+
conditions_start = and_pos + 3 # skip "and"
|
|
2006
|
+
while conditions_start < len && whitespace?(query.getbyte(conditions_start))
|
|
2007
|
+
conditions_start += 1
|
|
1790
2008
|
end
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
result << "''"
|
|
1794
|
-
elsif quote_char
|
|
1795
|
-
result << quote_char.chr << url_str << quote_char.chr
|
|
2009
|
+
conditions = query.byteslice(conditions_start, len - conditions_start)
|
|
2010
|
+
[type, conditions]
|
|
1796
2011
|
else
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
# Skip past closing quote if present
|
|
1801
|
-
pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
|
|
1802
|
-
|
|
1803
|
-
# Skip whitespace before )
|
|
1804
|
-
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1805
|
-
pos += 1
|
|
2012
|
+
# No "and" found - rest is conditions (unusual but possible)
|
|
2013
|
+
[type, query.byteslice(i, len - i)]
|
|
1806
2014
|
end
|
|
1807
|
-
|
|
1808
|
-
# The ) will be copied in the next iteration or at the end
|
|
1809
|
-
else
|
|
1810
|
-
result << byte.chr
|
|
1811
|
-
pos += 1
|
|
1812
|
-
end
|
|
1813
|
-
end
|
|
1814
|
-
|
|
1815
|
-
result
|
|
1816
|
-
end
|
|
1817
|
-
|
|
1818
|
-
# Parse a block of declarations given start/end positions
|
|
1819
|
-
# Used for @font-face and other at-rules
|
|
1820
|
-
# Translated from C: see ext/cataract/css_parser.c parse_declarations
|
|
1821
|
-
def parse_declarations_block(start_pos, end_pos)
|
|
1822
|
-
declarations = []
|
|
1823
|
-
pos = start_pos
|
|
1824
|
-
|
|
1825
|
-
while pos < end_pos
|
|
1826
|
-
# Skip whitespace
|
|
1827
|
-
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1828
|
-
pos += 1
|
|
1829
|
-
end
|
|
1830
|
-
break if pos >= end_pos
|
|
1831
|
-
|
|
1832
|
-
# Parse declaration using shared helper (at-rules don't use !important)
|
|
1833
|
-
decl, pos = parse_single_declaration(pos, end_pos, false)
|
|
1834
|
-
declarations << decl if decl
|
|
1835
|
-
end
|
|
1836
|
-
|
|
1837
|
-
declarations
|
|
1838
|
-
end
|
|
1839
|
-
|
|
1840
|
-
# Combine parent and child media query parts directly without string building
|
|
1841
|
-
#
|
|
1842
|
-
# The parent's type takes precedence (child type is ignored per CSS spec).
|
|
1843
|
-
#
|
|
1844
|
-
# @param parent_mq [MediaQuery] Parent media query object
|
|
1845
|
-
# @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
|
|
1846
|
-
# @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
|
|
1847
|
-
#
|
|
1848
|
-
# @example
|
|
1849
|
-
# combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
|
|
1850
|
-
def combine_media_query_parts(parent_mq, child_conditions)
|
|
1851
|
-
# Type: parent's type wins (outermost type)
|
|
1852
|
-
combined_type = parent_mq.type
|
|
1853
|
-
|
|
1854
|
-
# Conditions: combine parent and child conditions
|
|
1855
|
-
combined_conditions = if parent_mq.conditions && child_conditions
|
|
1856
|
-
"#{parent_mq.conditions} and #{child_conditions}"
|
|
1857
|
-
elsif parent_mq.conditions
|
|
1858
|
-
parent_mq.conditions
|
|
1859
|
-
elsif child_conditions
|
|
1860
|
-
child_conditions
|
|
1861
|
-
end
|
|
1862
|
-
|
|
1863
|
-
[combined_type, combined_conditions]
|
|
1864
|
-
end
|
|
1865
|
-
|
|
1866
|
-
# Parse media query string into type and conditions
|
|
1867
|
-
#
|
|
1868
|
-
# @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
|
|
1869
|
-
# @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
|
|
1870
|
-
#
|
|
1871
|
-
# @example
|
|
1872
|
-
# parse_media_query_parts("screen") #=> [:screen, nil]
|
|
1873
|
-
# parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
|
|
1874
|
-
# parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
|
|
1875
|
-
def parse_media_query_parts(query)
|
|
1876
|
-
i = 0
|
|
1877
|
-
len = query.bytesize
|
|
1878
|
-
|
|
1879
|
-
# Skip leading whitespace
|
|
1880
|
-
while i < len && whitespace?(query.getbyte(i))
|
|
1881
|
-
i += 1
|
|
1882
|
-
end
|
|
1883
|
-
|
|
1884
|
-
return [:all, nil] if i >= len
|
|
1885
|
-
|
|
1886
|
-
# Check if starts with '(' - media feature without type (defaults to :all)
|
|
1887
|
-
if query.getbyte(i) == BYTE_LPAREN
|
|
1888
|
-
return [:all, query.byteslice(i, len - i)]
|
|
1889
|
-
end
|
|
1890
|
-
|
|
1891
|
-
# Find first media type word
|
|
1892
|
-
word_start = i
|
|
1893
|
-
while i < len
|
|
1894
|
-
byte = query.getbyte(i)
|
|
1895
|
-
break if whitespace?(byte) || byte == BYTE_LPAREN
|
|
1896
|
-
|
|
1897
|
-
i += 1
|
|
1898
|
-
end
|
|
1899
|
-
|
|
1900
|
-
type = query.byteslice(word_start, i - word_start).to_sym
|
|
1901
|
-
|
|
1902
|
-
# Skip whitespace after type
|
|
1903
|
-
while i < len && whitespace?(query.getbyte(i))
|
|
1904
|
-
i += 1
|
|
1905
|
-
end
|
|
1906
|
-
|
|
1907
|
-
# Check if there's more (conditions)
|
|
1908
|
-
if i >= len
|
|
1909
|
-
return [type, nil]
|
|
1910
|
-
end
|
|
1911
|
-
|
|
1912
|
-
# Look for " and " keyword (case-insensitive)
|
|
1913
|
-
# We need to find "and" as a separate word
|
|
1914
|
-
and_pos = nil
|
|
1915
|
-
check_i = i
|
|
1916
|
-
while check_i < len - 2
|
|
1917
|
-
# Check for 'and' (a=97/65, n=110/78, d=100/68)
|
|
1918
|
-
byte0 = query.getbyte(check_i)
|
|
1919
|
-
byte1 = query.getbyte(check_i + 1)
|
|
1920
|
-
byte2 = query.getbyte(check_i + 2)
|
|
1921
|
-
|
|
1922
|
-
if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
|
|
1923
|
-
(byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
|
|
1924
|
-
(byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
|
|
1925
|
-
# Make sure it's a word boundary (whitespace before and after)
|
|
1926
|
-
before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
|
|
1927
|
-
after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
|
|
1928
|
-
if before_ok && after_ok
|
|
1929
|
-
and_pos = check_i
|
|
1930
|
-
break
|
|
1931
|
-
end
|
|
1932
|
-
end
|
|
1933
|
-
check_i += 1
|
|
1934
|
-
end
|
|
1935
|
-
|
|
1936
|
-
if and_pos
|
|
1937
|
-
# Skip past "and " to get conditions
|
|
1938
|
-
conditions_start = and_pos + 3 # skip "and"
|
|
1939
|
-
while conditions_start < len && whitespace?(query.getbyte(conditions_start))
|
|
1940
|
-
conditions_start += 1
|
|
1941
2015
|
end
|
|
1942
|
-
conditions = query.byteslice(conditions_start, len - conditions_start)
|
|
1943
|
-
[type, conditions]
|
|
1944
|
-
else
|
|
1945
|
-
# No "and" found - rest is conditions (unusual but possible)
|
|
1946
|
-
[type, query.byteslice(i, len - i)]
|
|
1947
2016
|
end
|
|
1948
2017
|
end
|
|
1949
2018
|
end
|