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