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