cataract 0.2.2 → 0.2.4
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/.rubocop.yml +7 -3
- data/BENCHMARKS.md +32 -32
- data/CHANGELOG.md +13 -0
- data/Gemfile +3 -0
- data/ext/cataract/cataract.c +219 -112
- data/ext/cataract/cataract.h +5 -1
- data/ext/cataract/css_parser.c +523 -33
- data/ext/cataract/flatten.c +233 -91
- data/ext/cataract/shorthand_expander.c +7 -0
- data/lib/cataract/at_rule.rb +2 -1
- data/lib/cataract/constants.rb +10 -0
- data/lib/cataract/import_resolver.rb +18 -87
- data/lib/cataract/import_statement.rb +29 -5
- data/lib/cataract/media_query.rb +98 -0
- data/lib/cataract/pure/byte_constants.rb +11 -0
- data/lib/cataract/pure/flatten.rb +127 -15
- data/lib/cataract/pure/parser.rb +654 -270
- data/lib/cataract/pure/serializer.rb +216 -115
- data/lib/cataract/pure.rb +6 -7
- data/lib/cataract/rule.rb +9 -5
- data/lib/cataract/stylesheet.rb +321 -99
- data/lib/cataract/stylesheet_scope.rb +10 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +4 -8
- data/lib/tasks/profile.rake +210 -0
- metadata +4 -2
- data/lib/cataract/pure/imports.rb +0 -268
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -29,15 +29,13 @@ module Cataract
|
|
|
29
29
|
|
|
30
30
|
AT_RULE_TYPES = %w[supports layer container scope].freeze
|
|
31
31
|
|
|
32
|
-
attr_reader :css, :pos, :len
|
|
33
|
-
|
|
34
32
|
# Extract substring and force specified encoding
|
|
35
33
|
# Per CSS spec, charset detection happens at byte-stream level before parsing.
|
|
36
34
|
# All parsing operations treat content as UTF-8 (spec requires fallback to UTF-8).
|
|
37
35
|
# This prevents ArgumentError on broken/invalid encodings when calling string methods.
|
|
38
36
|
# Optional encoding parameter (default: 'UTF-8', use 'US-ASCII' for property names)
|
|
39
37
|
def byteslice_encoded(start, length, encoding: 'UTF-8')
|
|
40
|
-
@
|
|
38
|
+
@_css.byteslice(start, length).force_encoding(encoding)
|
|
41
39
|
end
|
|
42
40
|
|
|
43
41
|
# Helper: Case-insensitive ASCII byte comparison
|
|
@@ -65,31 +63,47 @@ module Cataract
|
|
|
65
63
|
true
|
|
66
64
|
end
|
|
67
65
|
|
|
68
|
-
def initialize(css_string, parser_options: {}, parent_media_sym: nil, depth: 0)
|
|
69
|
-
|
|
70
|
-
@
|
|
71
|
-
@
|
|
72
|
-
@
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@
|
|
76
|
-
|
|
66
|
+
def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
|
|
67
|
+
# Private: Internal parsing state
|
|
68
|
+
@_css = css_string.dup.freeze
|
|
69
|
+
@_pos = 0
|
|
70
|
+
@_len = @_css.bytesize
|
|
71
|
+
@_parent_media_sym = parent_media_sym
|
|
72
|
+
@_parent_media_query_id = parent_media_query_id
|
|
73
|
+
@_depth = depth # Current recursion depth (passed from parent parser)
|
|
74
|
+
|
|
75
|
+
# Private: Parser options with defaults
|
|
76
|
+
@_parser_options = {
|
|
77
|
+
selector_lists: true,
|
|
78
|
+
base_uri: nil,
|
|
79
|
+
absolute_paths: false,
|
|
80
|
+
uri_resolver: nil
|
|
77
81
|
}.merge(parser_options)
|
|
78
82
|
|
|
79
|
-
# Extract
|
|
80
|
-
@
|
|
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
|
|
81
88
|
|
|
82
|
-
#
|
|
83
|
-
@
|
|
84
|
-
@_media_index = {} # Symbol => Array of rule IDs
|
|
85
|
-
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
89
|
+
# Private: Internal counters
|
|
90
|
+
@_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
|
|
86
91
|
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
92
|
+
@_next_media_query_list_id = 0 # Counter for media query list IDs
|
|
93
|
+
@_rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
94
|
+
@_media_query_count = 0 # Safety limit
|
|
95
|
+
|
|
96
|
+
# Public: Parser results (returned in parse result hash)
|
|
97
|
+
@rules = [] # Flat array of Rule structs
|
|
98
|
+
@media_queries = [] # Array of MediaQuery objects
|
|
99
|
+
@media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
|
|
87
100
|
@imports = [] # Array of ImportStatement structs
|
|
88
|
-
@rule_id_counter = 0 # Next rule ID (0-indexed)
|
|
89
|
-
@media_query_count = 0 # Safety limit
|
|
90
|
-
@_has_nesting = false # Set to true if any nested rules found
|
|
91
|
-
@depth = depth # Current recursion depth (passed from parent parser)
|
|
92
101
|
@charset = nil # @charset declaration
|
|
102
|
+
|
|
103
|
+
# Semi-private: Internal state exposed with _ prefix in result
|
|
104
|
+
@_selector_lists = {} # Hash: list_id => Array of rule IDs
|
|
105
|
+
@_media_query_lists = {} # Hash: list_id => Array of MediaQuery IDs (for "screen, print")
|
|
106
|
+
@_has_nesting = false # Set to true if any nested rules found
|
|
93
107
|
end
|
|
94
108
|
|
|
95
109
|
def parse
|
|
@@ -118,7 +132,7 @@ module Cataract
|
|
|
118
132
|
end
|
|
119
133
|
|
|
120
134
|
# Find the block boundaries
|
|
121
|
-
decl_start = @
|
|
135
|
+
decl_start = @_pos # Should be right after the {
|
|
122
136
|
decl_end = find_matching_brace(decl_start)
|
|
123
137
|
|
|
124
138
|
# Check if block has nested selectors
|
|
@@ -132,18 +146,18 @@ module Cataract
|
|
|
132
146
|
next if individual_selector.empty?
|
|
133
147
|
|
|
134
148
|
# Get rule ID for this selector
|
|
135
|
-
current_rule_id = @
|
|
136
|
-
@
|
|
149
|
+
current_rule_id = @_rule_id_counter
|
|
150
|
+
@_rule_id_counter += 1
|
|
137
151
|
|
|
138
152
|
# Reserve parent's position in rules array (ensures parent comes before nested)
|
|
139
153
|
parent_position = @rules.length
|
|
140
154
|
@rules << nil # Placeholder
|
|
141
155
|
|
|
142
156
|
# Parse mixed block (declarations + nested selectors)
|
|
143
|
-
@
|
|
157
|
+
@_depth += 1
|
|
144
158
|
parent_declarations = parse_mixed_block(decl_start, decl_end,
|
|
145
|
-
individual_selector, current_rule_id, @
|
|
146
|
-
@
|
|
159
|
+
individual_selector, current_rule_id, @_parent_media_sym, @_parent_media_query_id)
|
|
160
|
+
@_depth -= 1
|
|
147
161
|
|
|
148
162
|
# Create parent rule and replace placeholder
|
|
149
163
|
rule = Rule.new(
|
|
@@ -156,16 +170,14 @@ module Cataract
|
|
|
156
170
|
)
|
|
157
171
|
|
|
158
172
|
@rules[parent_position] = rule
|
|
159
|
-
@_media_index[@parent_media_sym] ||= [] if @parent_media_sym
|
|
160
|
-
@_media_index[@parent_media_sym] << current_rule_id if @parent_media_sym
|
|
161
173
|
end
|
|
162
174
|
|
|
163
175
|
# Move position past the closing brace
|
|
164
|
-
@
|
|
165
|
-
@
|
|
176
|
+
@_pos = decl_end
|
|
177
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
166
178
|
else
|
|
167
179
|
# NON-NESTED PATH: Parse declarations only
|
|
168
|
-
@
|
|
180
|
+
@_pos = decl_start # Reset to start of block
|
|
169
181
|
declarations = parse_declarations
|
|
170
182
|
|
|
171
183
|
# Split comma-separated selectors into individual rules
|
|
@@ -174,7 +186,7 @@ module Cataract
|
|
|
174
186
|
# Determine if we should track this as a selector list
|
|
175
187
|
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
176
188
|
list_id = nil
|
|
177
|
-
if @
|
|
189
|
+
if @_selector_lists_enabled && selectors.size > 1
|
|
178
190
|
list_id = @_next_selector_list_id
|
|
179
191
|
@_next_selector_list_id += 1
|
|
180
192
|
@_selector_lists[list_id] = []
|
|
@@ -184,7 +196,7 @@ module Cataract
|
|
|
184
196
|
individual_selector.strip!
|
|
185
197
|
next if individual_selector.empty?
|
|
186
198
|
|
|
187
|
-
rule_id = @
|
|
199
|
+
rule_id = @_rule_id_counter
|
|
188
200
|
|
|
189
201
|
# Dup declarations for each rule in a selector list to avoid shared state
|
|
190
202
|
# (principle of least surprise - modifying one rule shouldn't affect others)
|
|
@@ -207,7 +219,7 @@ module Cataract
|
|
|
207
219
|
)
|
|
208
220
|
|
|
209
221
|
@rules << rule
|
|
210
|
-
@
|
|
222
|
+
@_rule_id_counter += 1
|
|
211
223
|
|
|
212
224
|
# Track in selector list if applicable
|
|
213
225
|
@_selector_lists[list_id] << rule_id if list_id
|
|
@@ -217,8 +229,10 @@ module Cataract
|
|
|
217
229
|
|
|
218
230
|
{
|
|
219
231
|
rules: @rules,
|
|
220
|
-
_media_index: @
|
|
232
|
+
_media_index: @media_index,
|
|
233
|
+
media_queries: @media_queries,
|
|
221
234
|
_selector_lists: @_selector_lists,
|
|
235
|
+
_media_query_lists: @_media_query_lists,
|
|
222
236
|
imports: @imports,
|
|
223
237
|
charset: @charset,
|
|
224
238
|
_has_nesting: @_has_nesting
|
|
@@ -229,7 +243,7 @@ module Cataract
|
|
|
229
243
|
|
|
230
244
|
# Check if we're at end of input
|
|
231
245
|
def eof?
|
|
232
|
-
@
|
|
246
|
+
@_pos >= @_len
|
|
233
247
|
end
|
|
234
248
|
|
|
235
249
|
# Peek current byte without advancing
|
|
@@ -237,7 +251,7 @@ module Cataract
|
|
|
237
251
|
def peek_byte
|
|
238
252
|
return nil if eof?
|
|
239
253
|
|
|
240
|
-
@
|
|
254
|
+
@_css.getbyte(@_pos)
|
|
241
255
|
end
|
|
242
256
|
|
|
243
257
|
# Delegate to module-level helper methods (now work with bytes)
|
|
@@ -258,19 +272,19 @@ module Cataract
|
|
|
258
272
|
end
|
|
259
273
|
|
|
260
274
|
def skip_whitespace
|
|
261
|
-
@
|
|
275
|
+
@_pos += 1 while !eof? && whitespace?(peek_byte)
|
|
262
276
|
end
|
|
263
277
|
|
|
264
278
|
def skip_comment # rubocop:disable Naming/PredicateMethod
|
|
265
|
-
return false unless peek_byte == BYTE_SLASH && @
|
|
279
|
+
return false unless peek_byte == BYTE_SLASH && @_css.getbyte(@_pos + 1) == BYTE_STAR
|
|
266
280
|
|
|
267
|
-
@
|
|
268
|
-
while @
|
|
269
|
-
if @
|
|
270
|
-
@
|
|
281
|
+
@_pos += 2 # Skip /*
|
|
282
|
+
while @_pos + 1 < @_len
|
|
283
|
+
if @_css.getbyte(@_pos) == BYTE_STAR && @_css.getbyte(@_pos + 1) == BYTE_SLASH
|
|
284
|
+
@_pos += 2 # Skip */
|
|
271
285
|
return true
|
|
272
286
|
end
|
|
273
|
-
@
|
|
287
|
+
@_pos += 1
|
|
274
288
|
end
|
|
275
289
|
true
|
|
276
290
|
end
|
|
@@ -283,10 +297,10 @@ module Cataract
|
|
|
283
297
|
# Benchmark shows 15-51% speedup depending on YJIT
|
|
284
298
|
def skip_ws_and_comments
|
|
285
299
|
begin
|
|
286
|
-
old_pos = @
|
|
300
|
+
old_pos = @_pos
|
|
287
301
|
skip_whitespace
|
|
288
302
|
skip_comment
|
|
289
|
-
end until @
|
|
303
|
+
end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
|
|
290
304
|
end
|
|
291
305
|
|
|
292
306
|
# Parse a single CSS declaration (property: value)
|
|
@@ -301,24 +315,24 @@ module Cataract
|
|
|
301
315
|
def parse_single_declaration(pos, end_pos, parse_important)
|
|
302
316
|
# Parse property name (scan until ':')
|
|
303
317
|
prop_start = pos
|
|
304
|
-
while pos < end_pos && @
|
|
305
|
-
@
|
|
318
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
319
|
+
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
306
320
|
pos += 1
|
|
307
321
|
end
|
|
308
322
|
|
|
309
323
|
# Skip if malformed (no colon found)
|
|
310
|
-
if pos >= end_pos || @
|
|
324
|
+
if pos >= end_pos || @_css.getbyte(pos) != BYTE_COLON
|
|
311
325
|
# Error recovery: skip to next semicolon
|
|
312
|
-
while pos < end_pos && @
|
|
326
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON
|
|
313
327
|
pos += 1
|
|
314
328
|
end
|
|
315
|
-
pos += 1 if pos < end_pos && @
|
|
329
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
316
330
|
return [nil, pos]
|
|
317
331
|
end
|
|
318
332
|
|
|
319
333
|
# Trim trailing whitespace from property
|
|
320
334
|
prop_end = pos
|
|
321
|
-
while prop_end > prop_start && whitespace?(@
|
|
335
|
+
while prop_end > prop_start && whitespace?(@_css.getbyte(prop_end - 1))
|
|
322
336
|
prop_end -= 1
|
|
323
337
|
end
|
|
324
338
|
|
|
@@ -334,19 +348,19 @@ module Cataract
|
|
|
334
348
|
pos += 1 # Skip ':'
|
|
335
349
|
|
|
336
350
|
# Skip leading whitespace in value
|
|
337
|
-
while pos < end_pos && whitespace?(@
|
|
351
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
338
352
|
pos += 1
|
|
339
353
|
end
|
|
340
354
|
|
|
341
355
|
# Parse value (scan until ';' or '}')
|
|
342
356
|
val_start = pos
|
|
343
|
-
while pos < end_pos && @
|
|
357
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
344
358
|
pos += 1
|
|
345
359
|
end
|
|
346
360
|
val_end = pos
|
|
347
361
|
|
|
348
362
|
# Trim trailing whitespace from value
|
|
349
|
-
while val_end > val_start && whitespace?(@
|
|
363
|
+
while val_end > val_start && whitespace?(@_css.getbyte(val_end - 1))
|
|
350
364
|
val_end -= 1
|
|
351
365
|
end
|
|
352
366
|
|
|
@@ -361,11 +375,14 @@ module Cataract
|
|
|
361
375
|
end
|
|
362
376
|
|
|
363
377
|
# Skip semicolon if present
|
|
364
|
-
pos += 1 if pos < end_pos && @
|
|
378
|
+
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
365
379
|
|
|
366
380
|
# Return nil if empty declaration
|
|
367
381
|
return [nil, pos] if prop_end <= prop_start || val_end <= val_start
|
|
368
382
|
|
|
383
|
+
# Convert relative URLs to absolute if enabled
|
|
384
|
+
value = convert_urls_in_value(value)
|
|
385
|
+
|
|
369
386
|
[Declaration.new(property, value, important), pos]
|
|
370
387
|
end
|
|
371
388
|
|
|
@@ -382,8 +399,8 @@ module Cataract
|
|
|
382
399
|
depth = 1
|
|
383
400
|
pos = start_pos
|
|
384
401
|
|
|
385
|
-
while pos < @
|
|
386
|
-
byte = @
|
|
402
|
+
while pos < @_len
|
|
403
|
+
byte = @_css.getbyte(pos)
|
|
387
404
|
if byte == BYTE_RBRACE
|
|
388
405
|
depth -= 1
|
|
389
406
|
return pos if depth == 0
|
|
@@ -398,21 +415,21 @@ module Cataract
|
|
|
398
415
|
|
|
399
416
|
# Parse selector (read until '{')
|
|
400
417
|
def parse_selector
|
|
401
|
-
start_pos = @
|
|
418
|
+
start_pos = @_pos
|
|
402
419
|
|
|
403
420
|
# Read until we find '{'
|
|
404
421
|
until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
405
|
-
@
|
|
422
|
+
@_pos += 1
|
|
406
423
|
end
|
|
407
424
|
|
|
408
425
|
# If we hit EOF without finding '{', return nil
|
|
409
426
|
return nil if eof?
|
|
410
427
|
|
|
411
428
|
# Extract selector text
|
|
412
|
-
selector_text = byteslice_encoded(start_pos, @
|
|
429
|
+
selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
|
|
413
430
|
|
|
414
431
|
# Skip the '{'
|
|
415
|
-
@
|
|
432
|
+
@_pos += 1 if peek_byte == BYTE_LBRACE
|
|
416
433
|
|
|
417
434
|
# Trim whitespace from selector (in-place to avoid allocation)
|
|
418
435
|
selector_text.strip!
|
|
@@ -422,9 +439,9 @@ module Cataract
|
|
|
422
439
|
# Parse mixed block containing declarations AND nested selectors/at-rules
|
|
423
440
|
# Translated from C: see ext/cataract/css_parser.c parse_mixed_block
|
|
424
441
|
# Returns: Array of declarations (only the declarations, not nested rules)
|
|
425
|
-
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym)
|
|
442
|
+
def parse_mixed_block(start_pos, end_pos, parent_selector, parent_rule_id, parent_media_sym, parent_media_query_id = nil)
|
|
426
443
|
# Check recursion depth to prevent stack overflow
|
|
427
|
-
if @
|
|
444
|
+
if @_depth > MAX_PARSE_DEPTH
|
|
428
445
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
429
446
|
end
|
|
430
447
|
|
|
@@ -433,16 +450,16 @@ module Cataract
|
|
|
433
450
|
|
|
434
451
|
while pos < end_pos
|
|
435
452
|
# Skip whitespace and comments
|
|
436
|
-
while pos < end_pos && whitespace?(@
|
|
453
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
437
454
|
pos += 1
|
|
438
455
|
end
|
|
439
456
|
break if pos >= end_pos
|
|
440
457
|
|
|
441
458
|
# Skip comments
|
|
442
|
-
if pos + 1 < end_pos && @
|
|
459
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
443
460
|
pos += 2
|
|
444
461
|
while pos + 1 < end_pos
|
|
445
|
-
if @
|
|
462
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
446
463
|
pos += 2
|
|
447
464
|
break
|
|
448
465
|
end
|
|
@@ -452,25 +469,25 @@ module Cataract
|
|
|
452
469
|
end
|
|
453
470
|
|
|
454
471
|
# Check if this is a nested @media query
|
|
455
|
-
if @
|
|
472
|
+
if @_css.getbyte(pos) == BYTE_AT && pos + 6 < end_pos &&
|
|
456
473
|
byteslice_encoded(pos, 6) == '@media' &&
|
|
457
|
-
(pos + 6 >= end_pos || whitespace?(@
|
|
474
|
+
(pos + 6 >= end_pos || whitespace?(@_css.getbyte(pos + 6)))
|
|
458
475
|
# Nested @media - parse with parent selector as context
|
|
459
476
|
media_start = pos + 6
|
|
460
|
-
while media_start < end_pos && whitespace?(@
|
|
477
|
+
while media_start < end_pos && whitespace?(@_css.getbyte(media_start))
|
|
461
478
|
media_start += 1
|
|
462
479
|
end
|
|
463
480
|
|
|
464
481
|
# Find opening brace
|
|
465
482
|
media_query_end = media_start
|
|
466
|
-
while media_query_end < end_pos && @
|
|
483
|
+
while media_query_end < end_pos && @_css.getbyte(media_query_end) != BYTE_LBRACE
|
|
467
484
|
media_query_end += 1
|
|
468
485
|
end
|
|
469
486
|
break if media_query_end >= end_pos
|
|
470
487
|
|
|
471
488
|
# Extract media query (trim trailing whitespace)
|
|
472
489
|
media_query_end_trimmed = media_query_end
|
|
473
|
-
while media_query_end_trimmed > media_start && whitespace?(@
|
|
490
|
+
while media_query_end_trimmed > media_start && whitespace?(@_css.getbyte(media_query_end_trimmed - 1))
|
|
474
491
|
media_query_end_trimmed -= 1
|
|
475
492
|
end
|
|
476
493
|
media_query_str = byteslice_encoded(media_start, media_query_end_trimmed - media_start)
|
|
@@ -489,15 +506,48 @@ module Cataract
|
|
|
489
506
|
# Combine media queries: parent + child
|
|
490
507
|
combined_media_sym = combine_media_queries(parent_media_sym, media_sym)
|
|
491
508
|
|
|
509
|
+
# Create MediaQuery object for this nested @media
|
|
510
|
+
# If we're already in a media query context, combine with parent
|
|
511
|
+
nested_media_query_id = if parent_media_query_id
|
|
512
|
+
# Combine with parent MediaQuery
|
|
513
|
+
parent_mq = @media_queries[parent_media_query_id]
|
|
514
|
+
|
|
515
|
+
# This should never happen - parent_media_query_id should always be valid
|
|
516
|
+
if parent_mq.nil?
|
|
517
|
+
raise ParserError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Combine parent media query with child
|
|
521
|
+
_child_type, child_conditions = parse_media_query_parts(media_query_str)
|
|
522
|
+
combined_type, combined_conditions = combine_media_query_parts(parent_mq, child_conditions)
|
|
523
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
524
|
+
@media_queries << combined_mq
|
|
525
|
+
combined_id = @_media_query_id_counter
|
|
526
|
+
@_media_query_id_counter += 1
|
|
527
|
+
combined_id
|
|
528
|
+
else
|
|
529
|
+
# No parent context, just use the child media query
|
|
530
|
+
media_type, media_conditions = parse_media_query_parts(media_query_str)
|
|
531
|
+
nested_media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
532
|
+
@media_queries << nested_media_query
|
|
533
|
+
mq_id = @_media_query_id_counter
|
|
534
|
+
@_media_query_id_counter += 1
|
|
535
|
+
mq_id
|
|
536
|
+
end
|
|
537
|
+
|
|
492
538
|
# Create rule ID for this media rule
|
|
493
|
-
media_rule_id = @
|
|
494
|
-
@
|
|
539
|
+
media_rule_id = @_rule_id_counter
|
|
540
|
+
@_rule_id_counter += 1
|
|
541
|
+
|
|
542
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
543
|
+
rule_position = @rules.length
|
|
544
|
+
@rules << nil # Placeholder
|
|
495
545
|
|
|
496
|
-
# Parse mixed block recursively
|
|
497
|
-
@
|
|
546
|
+
# Parse mixed block recursively with the nested media query ID as context
|
|
547
|
+
@_depth += 1
|
|
498
548
|
media_declarations = parse_mixed_block(media_block_start, media_block_end,
|
|
499
|
-
parent_selector, media_rule_id, combined_media_sym)
|
|
500
|
-
@
|
|
549
|
+
parent_selector, media_rule_id, combined_media_sym, nested_media_query_id)
|
|
550
|
+
@_depth -= 1
|
|
501
551
|
|
|
502
552
|
# Create rule with parent selector and declarations, associated with combined media query
|
|
503
553
|
rule = Rule.new(
|
|
@@ -506,34 +556,34 @@ module Cataract
|
|
|
506
556
|
media_declarations,
|
|
507
557
|
nil, # specificity
|
|
508
558
|
parent_rule_id,
|
|
509
|
-
nil
|
|
559
|
+
nil, # nesting_style (nil for @media nesting)
|
|
560
|
+
nil, # selector_list_id
|
|
561
|
+
nested_media_query_id # media_query_id
|
|
510
562
|
)
|
|
511
563
|
|
|
512
564
|
# Mark that we have nesting
|
|
513
565
|
@_has_nesting = true unless parent_rule_id.nil?
|
|
514
566
|
|
|
515
|
-
|
|
516
|
-
@
|
|
517
|
-
@_media_index[combined_media_sym] << media_rule_id
|
|
518
|
-
|
|
567
|
+
# Replace placeholder with actual rule
|
|
568
|
+
@rules[rule_position] = rule
|
|
519
569
|
next
|
|
520
570
|
end
|
|
521
571
|
|
|
522
572
|
# Check if this is a nested selector
|
|
523
|
-
byte = @
|
|
573
|
+
byte = @_css.getbyte(pos)
|
|
524
574
|
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
525
575
|
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
526
576
|
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE || byte == BYTE_AT
|
|
527
577
|
# Find the opening brace
|
|
528
578
|
nested_sel_start = pos
|
|
529
|
-
while pos < end_pos && @
|
|
579
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_LBRACE
|
|
530
580
|
pos += 1
|
|
531
581
|
end
|
|
532
582
|
break if pos >= end_pos
|
|
533
583
|
|
|
534
584
|
nested_sel_end = pos
|
|
535
585
|
# Trim trailing whitespace
|
|
536
|
-
while nested_sel_end > nested_sel_start && whitespace?(@
|
|
586
|
+
while nested_sel_end > nested_sel_start && whitespace?(@_css.getbyte(nested_sel_end - 1))
|
|
537
587
|
nested_sel_end -= 1
|
|
538
588
|
end
|
|
539
589
|
|
|
@@ -557,14 +607,18 @@ module Cataract
|
|
|
557
607
|
resolved_selector, nesting_style = resolve_nested_selector(parent_selector, seg)
|
|
558
608
|
|
|
559
609
|
# Get rule ID
|
|
560
|
-
rule_id = @
|
|
561
|
-
@
|
|
610
|
+
rule_id = @_rule_id_counter
|
|
611
|
+
@_rule_id_counter += 1
|
|
612
|
+
|
|
613
|
+
# Reserve position in rules array (ensures sequential IDs match array indices)
|
|
614
|
+
rule_position = @rules.length
|
|
615
|
+
@rules << nil # Placeholder
|
|
562
616
|
|
|
563
617
|
# Recursively parse nested block
|
|
564
|
-
@
|
|
618
|
+
@_depth += 1
|
|
565
619
|
nested_declarations = parse_mixed_block(nested_block_start, nested_block_end,
|
|
566
|
-
resolved_selector, rule_id, parent_media_sym)
|
|
567
|
-
@
|
|
620
|
+
resolved_selector, rule_id, parent_media_sym, parent_media_query_id)
|
|
621
|
+
@_depth -= 1
|
|
568
622
|
|
|
569
623
|
# Create rule for nested selector
|
|
570
624
|
rule = Rule.new(
|
|
@@ -579,9 +633,8 @@ module Cataract
|
|
|
579
633
|
# Mark that we have nesting
|
|
580
634
|
@_has_nesting = true unless parent_rule_id.nil?
|
|
581
635
|
|
|
582
|
-
|
|
583
|
-
@
|
|
584
|
-
@_media_index[parent_media_sym] << rule_id if parent_media_sym
|
|
636
|
+
# Replace placeholder with actual rule
|
|
637
|
+
@rules[rule_position] = rule
|
|
585
638
|
end
|
|
586
639
|
|
|
587
640
|
next
|
|
@@ -607,17 +660,17 @@ module Cataract
|
|
|
607
660
|
|
|
608
661
|
# Check for closing brace
|
|
609
662
|
if peek_byte == BYTE_RBRACE
|
|
610
|
-
@
|
|
663
|
+
@_pos += 1 # consume '}'
|
|
611
664
|
break
|
|
612
665
|
end
|
|
613
666
|
|
|
614
667
|
# Parse property name (read until ':')
|
|
615
|
-
property_start = @
|
|
668
|
+
property_start = @_pos
|
|
616
669
|
until eof?
|
|
617
670
|
byte = peek_byte
|
|
618
671
|
break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
619
672
|
|
|
620
|
-
@
|
|
673
|
+
@_pos += 1
|
|
621
674
|
end
|
|
622
675
|
|
|
623
676
|
# Skip if no colon found (malformed)
|
|
@@ -628,7 +681,7 @@ module Cataract
|
|
|
628
681
|
end
|
|
629
682
|
|
|
630
683
|
# Extract property name - use UTF-8 encoding to support custom properties with Unicode
|
|
631
|
-
property = byteslice_encoded(property_start, @
|
|
684
|
+
property = byteslice_encoded(property_start, @_pos - property_start)
|
|
632
685
|
property.strip!
|
|
633
686
|
# Custom properties (--foo) are case-sensitive and can contain Unicode
|
|
634
687
|
# Regular properties are ASCII-only and case-insensitive
|
|
@@ -637,22 +690,39 @@ module Cataract
|
|
|
637
690
|
property.force_encoding('US-ASCII')
|
|
638
691
|
property.downcase!
|
|
639
692
|
end
|
|
640
|
-
@
|
|
693
|
+
@_pos += 1 # skip ':'
|
|
641
694
|
|
|
642
695
|
skip_ws_and_comments
|
|
643
696
|
|
|
644
|
-
# Parse value (read until ';' or '}')
|
|
645
|
-
value_start = @
|
|
697
|
+
# Parse value (read until ';' or '}', but respect quoted strings)
|
|
698
|
+
value_start = @_pos
|
|
646
699
|
important = false
|
|
700
|
+
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
647
701
|
|
|
648
702
|
until eof?
|
|
649
703
|
byte = peek_byte
|
|
650
|
-
break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
651
704
|
|
|
652
|
-
|
|
705
|
+
if in_quote
|
|
706
|
+
# Inside quoted string - only exit on matching quote
|
|
707
|
+
if byte == in_quote
|
|
708
|
+
in_quote = nil
|
|
709
|
+
elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
710
|
+
# Skip escaped character
|
|
711
|
+
@_pos += 1
|
|
712
|
+
end
|
|
713
|
+
else
|
|
714
|
+
# Not in quote - check for terminators or quote start
|
|
715
|
+
break if byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
716
|
+
|
|
717
|
+
if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE
|
|
718
|
+
in_quote = byte
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
@_pos += 1
|
|
653
723
|
end
|
|
654
724
|
|
|
655
|
-
value = byteslice_encoded(value_start, @
|
|
725
|
+
value = byteslice_encoded(value_start, @_pos - value_start)
|
|
656
726
|
value.strip!
|
|
657
727
|
|
|
658
728
|
# Check for !important (byte-by-byte, no regexp)
|
|
@@ -688,7 +758,10 @@ module Cataract
|
|
|
688
758
|
end
|
|
689
759
|
|
|
690
760
|
# Skip semicolon if present
|
|
691
|
-
@
|
|
761
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
762
|
+
|
|
763
|
+
# Convert relative URLs to absolute if enabled
|
|
764
|
+
value = convert_urls_in_value(value)
|
|
692
765
|
|
|
693
766
|
# Create Declaration struct
|
|
694
767
|
declarations << Declaration.new(property, value, important)
|
|
@@ -700,35 +773,35 @@ module Cataract
|
|
|
700
773
|
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
701
774
|
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
702
775
|
def parse_at_rule
|
|
703
|
-
at_rule_start = @
|
|
704
|
-
@
|
|
776
|
+
at_rule_start = @_pos # Points to '@'
|
|
777
|
+
@_pos += 1 # skip '@'
|
|
705
778
|
|
|
706
779
|
# Find end of at-rule name (stop at whitespace or opening brace)
|
|
707
|
-
name_start = @
|
|
780
|
+
name_start = @_pos
|
|
708
781
|
until eof?
|
|
709
782
|
byte = peek_byte
|
|
710
783
|
break if whitespace?(byte) || byte == BYTE_LBRACE
|
|
711
784
|
|
|
712
|
-
@
|
|
785
|
+
@_pos += 1
|
|
713
786
|
end
|
|
714
787
|
|
|
715
|
-
at_rule_name = byteslice_encoded(name_start, @
|
|
788
|
+
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
716
789
|
|
|
717
790
|
# Handle @charset specially - it's just @charset "value";
|
|
718
791
|
if at_rule_name == 'charset'
|
|
719
792
|
skip_ws_and_comments
|
|
720
793
|
# Read until semicolon
|
|
721
|
-
value_start = @
|
|
794
|
+
value_start = @_pos
|
|
722
795
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
723
|
-
@
|
|
796
|
+
@_pos += 1
|
|
724
797
|
end
|
|
725
798
|
|
|
726
|
-
charset_value = byteslice_encoded(value_start, @
|
|
799
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
727
800
|
charset_value.strip!
|
|
728
801
|
# Remove quotes
|
|
729
802
|
@charset = charset_value.delete('"\'')
|
|
730
803
|
|
|
731
|
-
@
|
|
804
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
732
805
|
return
|
|
733
806
|
end
|
|
734
807
|
|
|
@@ -739,9 +812,9 @@ module Cataract
|
|
|
739
812
|
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
740
813
|
# Skip to semicolon
|
|
741
814
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
742
|
-
@
|
|
815
|
+
@_pos += 1
|
|
743
816
|
end
|
|
744
|
-
@
|
|
817
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
745
818
|
return
|
|
746
819
|
end
|
|
747
820
|
|
|
@@ -756,28 +829,28 @@ module Cataract
|
|
|
756
829
|
|
|
757
830
|
# Skip to opening brace
|
|
758
831
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
759
|
-
@
|
|
832
|
+
@_pos += 1
|
|
760
833
|
end
|
|
761
834
|
|
|
762
835
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
763
836
|
|
|
764
|
-
@
|
|
837
|
+
@_pos += 1 # skip '{'
|
|
765
838
|
|
|
766
839
|
# Find matching closing brace
|
|
767
|
-
block_start = @
|
|
768
|
-
block_end = find_matching_brace(@
|
|
840
|
+
block_start = @_pos
|
|
841
|
+
block_end = find_matching_brace(@_pos)
|
|
769
842
|
|
|
770
843
|
# Check depth before recursing
|
|
771
|
-
if @
|
|
844
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
772
845
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
773
846
|
end
|
|
774
847
|
|
|
775
848
|
# Recursively parse block content (preserve parent media context)
|
|
776
849
|
nested_parser = Parser.new(
|
|
777
850
|
byteslice_encoded(block_start, block_end - block_start),
|
|
778
|
-
parser_options: @
|
|
779
|
-
parent_media_sym: @
|
|
780
|
-
depth: @
|
|
851
|
+
parser_options: @_parser_options,
|
|
852
|
+
parent_media_sym: @_parent_media_sym,
|
|
853
|
+
depth: @_depth + 1
|
|
781
854
|
)
|
|
782
855
|
|
|
783
856
|
nested_result = nested_parser.parse
|
|
@@ -787,33 +860,29 @@ module Cataract
|
|
|
787
860
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
788
861
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
789
862
|
new_list_id = list_id + list_id_offset
|
|
790
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
863
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
791
864
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
792
865
|
end
|
|
793
866
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
794
867
|
end
|
|
795
868
|
|
|
796
|
-
#
|
|
797
|
-
|
|
798
|
-
@_media_index[media] ||= []
|
|
799
|
-
# Use each + << instead of concat + map (1.20x faster for small arrays)
|
|
800
|
-
rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
|
|
801
|
-
end
|
|
869
|
+
# NOTE: We no longer build media_index during parse
|
|
870
|
+
# It will be built from MediaQuery objects after import resolution
|
|
802
871
|
|
|
803
872
|
# Add nested rules to main rules array
|
|
804
873
|
nested_result[:rules].each do |rule|
|
|
805
|
-
rule.id = @
|
|
874
|
+
rule.id = @_rule_id_counter
|
|
806
875
|
# Update selector_list_id if applicable
|
|
807
876
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
808
877
|
rule.selector_list_id += list_id_offset
|
|
809
878
|
end
|
|
810
|
-
@
|
|
879
|
+
@_rule_id_counter += 1
|
|
811
880
|
@rules << rule
|
|
812
881
|
end
|
|
813
882
|
|
|
814
883
|
# Move position past the closing brace
|
|
815
|
-
@
|
|
816
|
-
@
|
|
884
|
+
@_pos = block_end
|
|
885
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
817
886
|
|
|
818
887
|
return
|
|
819
888
|
end
|
|
@@ -823,16 +892,16 @@ module Cataract
|
|
|
823
892
|
skip_ws_and_comments
|
|
824
893
|
|
|
825
894
|
# Find media query (up to opening brace)
|
|
826
|
-
mq_start = @
|
|
895
|
+
mq_start = @_pos
|
|
827
896
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
828
|
-
@
|
|
897
|
+
@_pos += 1
|
|
829
898
|
end
|
|
830
899
|
|
|
831
900
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
832
901
|
|
|
833
|
-
mq_end = @
|
|
902
|
+
mq_end = @_pos
|
|
834
903
|
# Trim trailing whitespace
|
|
835
|
-
while mq_end > mq_start && whitespace?(@
|
|
904
|
+
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
836
905
|
mq_end -= 1
|
|
837
906
|
end
|
|
838
907
|
|
|
@@ -841,34 +910,65 @@ module Cataract
|
|
|
841
910
|
child_media_string.strip!
|
|
842
911
|
child_media_sym = child_media_string.to_sym
|
|
843
912
|
|
|
913
|
+
# Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
|
|
914
|
+
# Per W3C spec, comma acts as logical OR - each query is independent
|
|
915
|
+
media_query_strings = child_media_string.split(',').map(&:strip)
|
|
916
|
+
|
|
917
|
+
# Create MediaQuery objects for each query in the list
|
|
918
|
+
media_query_ids = []
|
|
919
|
+
media_query_strings.each do |query_string|
|
|
920
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
921
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
922
|
+
@media_queries << media_query
|
|
923
|
+
media_query_ids << @_media_query_id_counter
|
|
924
|
+
@_media_query_id_counter += 1
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# If multiple queries, track them as a list for serialization
|
|
928
|
+
if media_query_ids.size > 1
|
|
929
|
+
@_media_query_lists[@_next_media_query_list_id] = media_query_ids
|
|
930
|
+
@_next_media_query_list_id += 1
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# Use first query ID as the primary one for rules in this block
|
|
934
|
+
current_media_query_id = media_query_ids.first
|
|
935
|
+
|
|
844
936
|
# Combine with parent media context
|
|
845
|
-
combined_media_sym = combine_media_queries(@
|
|
937
|
+
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
938
|
+
|
|
939
|
+
# NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
|
|
940
|
+
# create separate parsers without passing parent_media_query_id (see nested_parser creation below).
|
|
941
|
+
# MediaQuery combining for nested @media happens in parse_mixed_block instead.
|
|
942
|
+
# So this is just an alias to current_media_query_id.
|
|
943
|
+
combined_media_query_id = current_media_query_id
|
|
846
944
|
|
|
847
945
|
# Check media query limit
|
|
848
|
-
unless @
|
|
849
|
-
@
|
|
850
|
-
if @
|
|
946
|
+
unless @media_index.key?(combined_media_sym)
|
|
947
|
+
@_media_query_count += 1
|
|
948
|
+
if @_media_query_count > MAX_MEDIA_QUERIES
|
|
851
949
|
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
852
950
|
end
|
|
853
951
|
end
|
|
854
952
|
|
|
855
|
-
@
|
|
953
|
+
@_pos += 1 # skip '{'
|
|
856
954
|
|
|
857
955
|
# Find matching closing brace
|
|
858
|
-
block_start = @
|
|
859
|
-
block_end = find_matching_brace(@
|
|
956
|
+
block_start = @_pos
|
|
957
|
+
block_end = find_matching_brace(@_pos)
|
|
860
958
|
|
|
861
959
|
# Check depth before recursing
|
|
862
|
-
if @
|
|
960
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
863
961
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
864
962
|
end
|
|
865
963
|
|
|
866
964
|
# Parse the content with the combined media context
|
|
965
|
+
# Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
|
|
966
|
+
# The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
|
|
867
967
|
nested_parser = Parser.new(
|
|
868
968
|
byteslice_encoded(block_start, block_end - block_start),
|
|
869
|
-
parser_options: @
|
|
969
|
+
parser_options: @_parser_options,
|
|
870
970
|
parent_media_sym: combined_media_sym,
|
|
871
|
-
depth: @
|
|
971
|
+
depth: @_depth + 1
|
|
872
972
|
)
|
|
873
973
|
|
|
874
974
|
nested_result = nested_parser.parse
|
|
@@ -878,50 +978,84 @@ module Cataract
|
|
|
878
978
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
879
979
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
880
980
|
new_list_id = list_id + list_id_offset
|
|
881
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
981
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
882
982
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
883
983
|
end
|
|
884
984
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
885
985
|
end
|
|
886
986
|
|
|
887
|
-
# Merge nested
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
987
|
+
# Merge nested MediaQuery objects with offsetted IDs
|
|
988
|
+
mq_id_offset = @_media_query_id_counter
|
|
989
|
+
if nested_result[:media_queries] && !nested_result[:media_queries].empty?
|
|
990
|
+
nested_result[:media_queries].each do |mq|
|
|
991
|
+
# Create new MediaQuery with offsetted ID
|
|
992
|
+
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
993
|
+
@media_queries << new_mq
|
|
994
|
+
end
|
|
995
|
+
@_media_query_id_counter += nested_result[:media_queries].size
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
# Merge nested media_query_lists with offsetted IDs
|
|
999
|
+
if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
|
|
1000
|
+
nested_result[:_media_query_lists].each do |list_id, mq_ids|
|
|
1001
|
+
# Offset the list_id and media_query_ids
|
|
1002
|
+
new_list_id = list_id + @_next_media_query_list_id
|
|
1003
|
+
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1004
|
+
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1005
|
+
end
|
|
1006
|
+
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
892
1007
|
end
|
|
893
1008
|
|
|
894
|
-
#
|
|
1009
|
+
# Merge nested media_index into ours (for nested @media)
|
|
1010
|
+
# Note: We no longer build media_index during parse
|
|
1011
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1012
|
+
|
|
1013
|
+
# Add nested rules to main rules array
|
|
895
1014
|
nested_result[:rules].each do |rule|
|
|
896
|
-
rule.id = @
|
|
1015
|
+
rule.id = @_rule_id_counter
|
|
897
1016
|
# Update selector_list_id if applicable
|
|
898
1017
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
899
1018
|
rule.selector_list_id += list_id_offset
|
|
900
1019
|
end
|
|
901
1020
|
|
|
902
|
-
#
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
@
|
|
1021
|
+
# Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
|
|
1022
|
+
if rule.media_query_id
|
|
1023
|
+
# Nested parser assigned a media_query_id - need to combine with our context
|
|
1024
|
+
nested_mq_id = rule.media_query_id + mq_id_offset
|
|
1025
|
+
nested_mq = @media_queries[nested_mq_id]
|
|
1026
|
+
|
|
1027
|
+
# Combine nested media query with our media context
|
|
1028
|
+
if nested_mq && combined_media_query_id
|
|
1029
|
+
outer_mq = @media_queries[combined_media_query_id]
|
|
1030
|
+
if outer_mq
|
|
1031
|
+
# Combine media queries directly without string building
|
|
1032
|
+
combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
|
|
1033
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
1034
|
+
@media_queries << combined_mq
|
|
1035
|
+
rule.media_query_id = @_media_query_id_counter
|
|
1036
|
+
@_media_query_id_counter += 1
|
|
1037
|
+
else
|
|
1038
|
+
rule.media_query_id = nested_mq_id
|
|
1039
|
+
end
|
|
1040
|
+
else
|
|
1041
|
+
rule.media_query_id = nested_mq_id
|
|
911
1042
|
end
|
|
1043
|
+
elsif rule.respond_to?(:media_query_id=)
|
|
1044
|
+
# Assign the combined media_query_id if no media_query_id set
|
|
1045
|
+
# (applies to both Rule and AtRule)
|
|
1046
|
+
rule.media_query_id = combined_media_query_id
|
|
912
1047
|
end
|
|
913
1048
|
|
|
914
|
-
#
|
|
915
|
-
|
|
916
|
-
@_media_index[combined_media_sym] << @rule_id_counter
|
|
1049
|
+
# NOTE: We no longer build media_index during parse
|
|
1050
|
+
# It will be built from MediaQuery objects after import resolution
|
|
917
1051
|
|
|
918
|
-
@
|
|
1052
|
+
@_rule_id_counter += 1
|
|
919
1053
|
@rules << rule
|
|
920
1054
|
end
|
|
921
1055
|
|
|
922
1056
|
# Move position past the closing brace
|
|
923
|
-
@
|
|
924
|
-
@
|
|
1057
|
+
@_pos = block_end
|
|
1058
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
925
1059
|
|
|
926
1060
|
return
|
|
927
1061
|
end
|
|
@@ -937,26 +1071,26 @@ module Cataract
|
|
|
937
1071
|
|
|
938
1072
|
# Skip to opening brace
|
|
939
1073
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
940
|
-
@
|
|
1074
|
+
@_pos += 1
|
|
941
1075
|
end
|
|
942
1076
|
|
|
943
1077
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
944
1078
|
|
|
945
|
-
selector_end = @
|
|
1079
|
+
selector_end = @_pos
|
|
946
1080
|
# Trim trailing whitespace
|
|
947
|
-
while selector_end > selector_start && whitespace?(@
|
|
1081
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
948
1082
|
selector_end -= 1
|
|
949
1083
|
end
|
|
950
1084
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
951
1085
|
|
|
952
|
-
@
|
|
1086
|
+
@_pos += 1 # skip '{'
|
|
953
1087
|
|
|
954
1088
|
# Find matching closing brace
|
|
955
|
-
block_start = @
|
|
956
|
-
block_end = find_matching_brace(@
|
|
1089
|
+
block_start = @_pos
|
|
1090
|
+
block_end = find_matching_brace(@_pos)
|
|
957
1091
|
|
|
958
1092
|
# Check depth before recursing
|
|
959
|
-
if @
|
|
1093
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
960
1094
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
961
1095
|
end
|
|
962
1096
|
|
|
@@ -964,23 +1098,23 @@ module Cataract
|
|
|
964
1098
|
# Create a nested parser context
|
|
965
1099
|
nested_parser = Parser.new(
|
|
966
1100
|
byteslice_encoded(block_start, block_end - block_start),
|
|
967
|
-
parser_options: @
|
|
968
|
-
depth: @
|
|
1101
|
+
parser_options: @_parser_options,
|
|
1102
|
+
depth: @_depth + 1
|
|
969
1103
|
)
|
|
970
1104
|
nested_result = nested_parser.parse
|
|
971
1105
|
content = nested_result[:rules]
|
|
972
1106
|
|
|
973
1107
|
# Move position past the closing brace
|
|
974
|
-
@
|
|
1108
|
+
@_pos = block_end
|
|
975
1109
|
# The closing brace should be at block_end
|
|
976
|
-
@
|
|
1110
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
977
1111
|
|
|
978
1112
|
# Get rule ID and increment
|
|
979
|
-
rule_id = @
|
|
980
|
-
@
|
|
1113
|
+
rule_id = @_rule_id_counter
|
|
1114
|
+
@_rule_id_counter += 1
|
|
981
1115
|
|
|
982
1116
|
# Create AtRule with nested rules
|
|
983
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1117
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
984
1118
|
@rules << at_rule
|
|
985
1119
|
|
|
986
1120
|
return
|
|
@@ -993,38 +1127,38 @@ module Cataract
|
|
|
993
1127
|
|
|
994
1128
|
# Skip to opening brace
|
|
995
1129
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
996
|
-
@
|
|
1130
|
+
@_pos += 1
|
|
997
1131
|
end
|
|
998
1132
|
|
|
999
1133
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1000
1134
|
|
|
1001
|
-
selector_end = @
|
|
1135
|
+
selector_end = @_pos
|
|
1002
1136
|
# Trim trailing whitespace
|
|
1003
|
-
while selector_end > selector_start && whitespace?(@
|
|
1137
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1004
1138
|
selector_end -= 1
|
|
1005
1139
|
end
|
|
1006
1140
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1007
1141
|
|
|
1008
|
-
@
|
|
1142
|
+
@_pos += 1 # skip '{'
|
|
1009
1143
|
|
|
1010
1144
|
# Find matching closing brace
|
|
1011
|
-
decl_start = @
|
|
1012
|
-
decl_end = find_matching_brace(@
|
|
1145
|
+
decl_start = @_pos
|
|
1146
|
+
decl_end = find_matching_brace(@_pos)
|
|
1013
1147
|
|
|
1014
1148
|
# Parse declarations
|
|
1015
1149
|
content = parse_declarations_block(decl_start, decl_end)
|
|
1016
1150
|
|
|
1017
1151
|
# Move position past the closing brace
|
|
1018
|
-
@
|
|
1152
|
+
@_pos = decl_end
|
|
1019
1153
|
# The closing brace should be at decl_end
|
|
1020
|
-
@
|
|
1154
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1021
1155
|
|
|
1022
1156
|
# Get rule ID and increment
|
|
1023
|
-
rule_id = @
|
|
1024
|
-
@
|
|
1157
|
+
rule_id = @_rule_id_counter
|
|
1158
|
+
@_rule_id_counter += 1
|
|
1025
1159
|
|
|
1026
1160
|
# Create AtRule with declarations
|
|
1027
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1161
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1028
1162
|
@rules << at_rule
|
|
1029
1163
|
|
|
1030
1164
|
return
|
|
@@ -1036,26 +1170,26 @@ module Cataract
|
|
|
1036
1170
|
|
|
1037
1171
|
# Skip to opening brace
|
|
1038
1172
|
until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
1039
|
-
@
|
|
1173
|
+
@_pos += 1
|
|
1040
1174
|
end
|
|
1041
1175
|
|
|
1042
1176
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1043
1177
|
|
|
1044
|
-
selector_end = @
|
|
1178
|
+
selector_end = @_pos
|
|
1045
1179
|
# Trim trailing whitespace
|
|
1046
|
-
while selector_end > selector_start && whitespace?(@
|
|
1180
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1047
1181
|
selector_end -= 1
|
|
1048
1182
|
end
|
|
1049
1183
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1050
1184
|
|
|
1051
|
-
@
|
|
1185
|
+
@_pos += 1 # skip '{'
|
|
1052
1186
|
|
|
1053
1187
|
# Parse declarations
|
|
1054
1188
|
declarations = parse_declarations
|
|
1055
1189
|
|
|
1056
1190
|
# Create Rule with declarations
|
|
1057
1191
|
rule = Rule.new(
|
|
1058
|
-
@
|
|
1192
|
+
@_rule_id_counter, # id
|
|
1059
1193
|
selector, # selector (e.g., "@property --main-color")
|
|
1060
1194
|
declarations, # declarations
|
|
1061
1195
|
nil, # specificity
|
|
@@ -1064,7 +1198,7 @@ module Cataract
|
|
|
1064
1198
|
)
|
|
1065
1199
|
|
|
1066
1200
|
@rules << rule
|
|
1067
|
-
@
|
|
1201
|
+
@_rule_id_counter += 1
|
|
1068
1202
|
end
|
|
1069
1203
|
|
|
1070
1204
|
# Check if block contains nested selectors vs just declarations
|
|
@@ -1074,16 +1208,16 @@ module Cataract
|
|
|
1074
1208
|
|
|
1075
1209
|
while pos < end_pos
|
|
1076
1210
|
# Skip whitespace
|
|
1077
|
-
while pos < end_pos && whitespace?(@
|
|
1211
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1078
1212
|
pos += 1
|
|
1079
1213
|
end
|
|
1080
1214
|
break if pos >= end_pos
|
|
1081
1215
|
|
|
1082
1216
|
# Skip comments
|
|
1083
|
-
if pos + 1 < end_pos && @
|
|
1217
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
1084
1218
|
pos += 2
|
|
1085
1219
|
while pos + 1 < end_pos
|
|
1086
|
-
if @
|
|
1220
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
1087
1221
|
pos += 2
|
|
1088
1222
|
break
|
|
1089
1223
|
end
|
|
@@ -1093,24 +1227,24 @@ module Cataract
|
|
|
1093
1227
|
end
|
|
1094
1228
|
|
|
1095
1229
|
# Check for nested selector indicators
|
|
1096
|
-
byte = @
|
|
1230
|
+
byte = @_css.getbyte(pos)
|
|
1097
1231
|
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
1098
1232
|
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
1099
1233
|
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
|
|
1100
1234
|
# Look ahead - if followed by {, it's likely a nested selector
|
|
1101
1235
|
lookahead = pos + 1
|
|
1102
|
-
while lookahead < end_pos && @
|
|
1103
|
-
@
|
|
1236
|
+
while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
|
|
1237
|
+
@_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
|
|
1104
1238
|
lookahead += 1
|
|
1105
1239
|
end
|
|
1106
|
-
return true if lookahead < end_pos && @
|
|
1240
|
+
return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
|
|
1107
1241
|
end
|
|
1108
1242
|
|
|
1109
1243
|
# Check for @media, @supports, etc nested inside
|
|
1110
1244
|
return true if byte == BYTE_AT
|
|
1111
1245
|
|
|
1112
1246
|
# Skip to next line or semicolon
|
|
1113
|
-
while pos < end_pos && @
|
|
1247
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
|
|
1114
1248
|
pos += 1
|
|
1115
1249
|
end
|
|
1116
1250
|
pos += 1 if pos < end_pos
|
|
@@ -1246,10 +1380,10 @@ module Cataract
|
|
|
1246
1380
|
# Skip to next semicolon or closing brace (error recovery)
|
|
1247
1381
|
def skip_to_semicolon_or_brace
|
|
1248
1382
|
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
|
|
1249
|
-
@
|
|
1383
|
+
@_pos += 1
|
|
1250
1384
|
end
|
|
1251
1385
|
|
|
1252
|
-
@
|
|
1386
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1253
1387
|
end
|
|
1254
1388
|
|
|
1255
1389
|
# Parse an @import statement
|
|
@@ -1260,9 +1394,9 @@ module Cataract
|
|
|
1260
1394
|
|
|
1261
1395
|
# Check for optional url(
|
|
1262
1396
|
has_url_function = false
|
|
1263
|
-
if @
|
|
1397
|
+
if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
|
|
1264
1398
|
has_url_function = true
|
|
1265
|
-
@
|
|
1399
|
+
@_pos += 4
|
|
1266
1400
|
skip_ws_and_comments
|
|
1267
1401
|
end
|
|
1268
1402
|
|
|
@@ -1271,24 +1405,24 @@ module Cataract
|
|
|
1271
1405
|
if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
|
|
1272
1406
|
# Invalid @import, skip to semicolon
|
|
1273
1407
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1274
|
-
@
|
|
1408
|
+
@_pos += 1
|
|
1275
1409
|
end
|
|
1276
|
-
@
|
|
1410
|
+
@_pos += 1 unless eof?
|
|
1277
1411
|
return
|
|
1278
1412
|
end
|
|
1279
1413
|
|
|
1280
1414
|
quote_char = byte
|
|
1281
|
-
@
|
|
1415
|
+
@_pos += 1 # Skip opening quote
|
|
1282
1416
|
|
|
1283
|
-
url_start = @
|
|
1417
|
+
url_start = @_pos
|
|
1284
1418
|
|
|
1285
1419
|
# Find closing quote (handle escaped quotes)
|
|
1286
1420
|
while !eof? && peek_byte != quote_char
|
|
1287
|
-
@
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1421
|
+
@_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
1422
|
+
2 # Skip escaped character
|
|
1423
|
+
else
|
|
1424
|
+
1
|
|
1425
|
+
end
|
|
1292
1426
|
end
|
|
1293
1427
|
|
|
1294
1428
|
if eof?
|
|
@@ -1296,87 +1430,227 @@ module Cataract
|
|
|
1296
1430
|
return
|
|
1297
1431
|
end
|
|
1298
1432
|
|
|
1299
|
-
url = byteslice_encoded(url_start, @
|
|
1300
|
-
@
|
|
1433
|
+
url = byteslice_encoded(url_start, @_pos - url_start)
|
|
1434
|
+
@_pos += 1 # Skip closing quote
|
|
1301
1435
|
|
|
1302
1436
|
# Skip closing paren if we had url(
|
|
1303
1437
|
if has_url_function
|
|
1304
1438
|
skip_ws_and_comments
|
|
1305
|
-
@
|
|
1439
|
+
@_pos += 1 if peek_byte == BYTE_RPAREN
|
|
1306
1440
|
end
|
|
1307
1441
|
|
|
1308
1442
|
skip_ws_and_comments
|
|
1309
1443
|
|
|
1310
1444
|
# Check for optional media query (everything until semicolon)
|
|
1311
|
-
|
|
1445
|
+
media_string = nil
|
|
1446
|
+
media_query_id = nil
|
|
1312
1447
|
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1313
|
-
media_start = @
|
|
1448
|
+
media_start = @_pos
|
|
1314
1449
|
|
|
1315
1450
|
# Find semicolon
|
|
1316
1451
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1317
|
-
@
|
|
1452
|
+
@_pos += 1
|
|
1318
1453
|
end
|
|
1319
1454
|
|
|
1320
|
-
media_end = @
|
|
1455
|
+
media_end = @_pos
|
|
1321
1456
|
|
|
1322
1457
|
# Trim trailing whitespace from media query
|
|
1323
|
-
while media_end > media_start && whitespace?(@
|
|
1458
|
+
while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
|
|
1324
1459
|
media_end -= 1
|
|
1325
1460
|
end
|
|
1326
1461
|
|
|
1327
1462
|
if media_end > media_start
|
|
1328
|
-
|
|
1463
|
+
media_string = byteslice_encoded(media_start, media_end - media_start)
|
|
1464
|
+
|
|
1465
|
+
# Split comma-separated media queries (e.g., "screen, handheld" -> ["screen", "handheld"])
|
|
1466
|
+
media_query_strings = media_string.split(',').map(&:strip)
|
|
1467
|
+
|
|
1468
|
+
# Create MediaQuery objects for each query in the list
|
|
1469
|
+
media_query_ids = []
|
|
1470
|
+
media_query_strings.each do |query_string|
|
|
1471
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1472
|
+
|
|
1473
|
+
# If we have a parent import's media context, combine them
|
|
1474
|
+
parent_import_type = @_parser_options[:parent_import_media_type]
|
|
1475
|
+
parent_import_conditions = @_parser_options[:parent_import_media_conditions]
|
|
1476
|
+
|
|
1477
|
+
if parent_import_type
|
|
1478
|
+
# Combine: parent's type is the effective type
|
|
1479
|
+
# Conditions are combined with "and"
|
|
1480
|
+
combined_type = parent_import_type
|
|
1481
|
+
combined_conditions = if parent_import_conditions && media_conditions
|
|
1482
|
+
"#{parent_import_conditions} and #{media_conditions}"
|
|
1483
|
+
elsif parent_import_conditions
|
|
1484
|
+
"#{parent_import_conditions} and #{media_type}#{" and #{media_conditions}" if media_conditions}"
|
|
1485
|
+
elsif media_conditions
|
|
1486
|
+
media_type == :all ? media_conditions : "#{media_type} and #{media_conditions}"
|
|
1487
|
+
else
|
|
1488
|
+
media_type == parent_import_type ? nil : media_type.to_s
|
|
1489
|
+
end
|
|
1490
|
+
|
|
1491
|
+
media_type = combined_type
|
|
1492
|
+
media_conditions = combined_conditions
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
# Create MediaQuery object
|
|
1496
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1497
|
+
@media_queries << media_query
|
|
1498
|
+
media_query_ids << @_media_query_id_counter
|
|
1499
|
+
@_media_query_id_counter += 1
|
|
1500
|
+
end
|
|
1501
|
+
|
|
1502
|
+
# Use the first media query ID for the import statement
|
|
1503
|
+
# (The list is tracked separately for serialization)
|
|
1504
|
+
media_query_id = media_query_ids.first
|
|
1505
|
+
|
|
1506
|
+
# If multiple queries, track them as a list for serialization
|
|
1507
|
+
if media_query_ids.size > 1
|
|
1508
|
+
media_query_list_id = @_next_media_query_list_id
|
|
1509
|
+
@_media_query_lists[media_query_list_id] = media_query_ids
|
|
1510
|
+
@_next_media_query_list_id += 1
|
|
1511
|
+
end
|
|
1329
1512
|
end
|
|
1330
1513
|
end
|
|
1331
1514
|
|
|
1332
1515
|
# Skip semicolon
|
|
1333
|
-
@
|
|
1516
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1334
1517
|
|
|
1335
1518
|
# Create ImportStatement (resolved: false by default)
|
|
1336
|
-
import_stmt = ImportStatement.new(@
|
|
1519
|
+
import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
|
|
1337
1520
|
@imports << import_stmt
|
|
1338
|
-
@
|
|
1521
|
+
@_rule_id_counter += 1
|
|
1339
1522
|
end
|
|
1340
1523
|
|
|
1341
|
-
#
|
|
1342
|
-
#
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1524
|
+
# Convert relative URLs in a value string to absolute URLs
|
|
1525
|
+
# Called when @_absolute_paths is enabled and @_base_uri is set
|
|
1526
|
+
#
|
|
1527
|
+
# @param value [String] The declaration value to process
|
|
1528
|
+
# @return [String] Value with relative URLs converted to absolute
|
|
1529
|
+
def convert_urls_in_value(value)
|
|
1530
|
+
return value unless @_absolute_paths && @_base_uri
|
|
1531
|
+
|
|
1532
|
+
result = +''
|
|
1533
|
+
pos = 0
|
|
1534
|
+
len = value.bytesize
|
|
1535
|
+
|
|
1536
|
+
while pos < len
|
|
1537
|
+
# Look for 'url(' - case insensitive
|
|
1538
|
+
byte = value.getbyte(pos)
|
|
1539
|
+
if pos + 3 < len &&
|
|
1540
|
+
(byte == BYTE_LOWER_U || byte == BYTE_UPPER_U) &&
|
|
1541
|
+
(value.getbyte(pos + 1) == BYTE_LOWER_R || value.getbyte(pos + 1) == BYTE_UPPER_R) &&
|
|
1542
|
+
(value.getbyte(pos + 2) == BYTE_LOWER_L || value.getbyte(pos + 2) == BYTE_UPPER_L) &&
|
|
1543
|
+
value.getbyte(pos + 3) == BYTE_LPAREN
|
|
1544
|
+
|
|
1545
|
+
result << value.byteslice(pos, 4) # append 'url('
|
|
1546
|
+
pos += 4
|
|
1547
|
+
|
|
1548
|
+
# Skip whitespace
|
|
1549
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1550
|
+
result << value.getbyte(pos).chr
|
|
1551
|
+
pos += 1
|
|
1552
|
+
end
|
|
1350
1553
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1554
|
+
# Check for quote
|
|
1555
|
+
quote_char = nil
|
|
1556
|
+
if pos < len && (value.getbyte(pos) == BYTE_SQUOTE || value.getbyte(pos) == BYTE_DQUOTE)
|
|
1557
|
+
quote_char = value.getbyte(pos)
|
|
1558
|
+
pos += 1
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# Extract URL
|
|
1562
|
+
url_start = pos
|
|
1563
|
+
if quote_char
|
|
1564
|
+
# Scan until matching quote
|
|
1565
|
+
while pos < len && value.getbyte(pos) != quote_char
|
|
1566
|
+
# Handle escape
|
|
1567
|
+
pos += if value.getbyte(pos) == BYTE_BACKSLASH && pos + 1 < len
|
|
1568
|
+
2
|
|
1569
|
+
else
|
|
1570
|
+
1
|
|
1571
|
+
end
|
|
1572
|
+
end
|
|
1573
|
+
else
|
|
1574
|
+
# Scan until ) or whitespace
|
|
1575
|
+
while pos < len
|
|
1576
|
+
b = value.getbyte(pos)
|
|
1577
|
+
break if b == BYTE_RPAREN || b == BYTE_SPACE || b == BYTE_TAB
|
|
1578
|
+
|
|
1579
|
+
pos += 1
|
|
1358
1580
|
end
|
|
1359
|
-
@pos += 1
|
|
1360
1581
|
end
|
|
1361
|
-
next
|
|
1362
|
-
end
|
|
1363
1582
|
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
# Check
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1583
|
+
url_str = value.byteslice(url_start, pos - url_start)
|
|
1584
|
+
|
|
1585
|
+
# Check if URL needs resolution (is relative)
|
|
1586
|
+
# Skip if: contains "://" OR starts with "data:"
|
|
1587
|
+
needs_resolution = true
|
|
1588
|
+
if url_str.empty?
|
|
1589
|
+
needs_resolution = false
|
|
1590
|
+
else
|
|
1591
|
+
# Check for "://"
|
|
1592
|
+
i = 0
|
|
1593
|
+
url_len = url_str.bytesize
|
|
1594
|
+
while i + 2 < url_len
|
|
1595
|
+
if url_str.getbyte(i) == BYTE_COLON &&
|
|
1596
|
+
url_str.getbyte(i + 1) == BYTE_SLASH &&
|
|
1597
|
+
url_str.getbyte(i + 2) == BYTE_SLASH
|
|
1598
|
+
needs_resolution = false
|
|
1599
|
+
break
|
|
1600
|
+
end
|
|
1601
|
+
i += 1
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
# Check for "data:" prefix (case insensitive)
|
|
1605
|
+
if needs_resolution && url_len >= 5
|
|
1606
|
+
if (url_str.getbyte(0) == BYTE_LOWER_D || url_str.getbyte(0) == BYTE_UPPER_D) &&
|
|
1607
|
+
(url_str.getbyte(1) == BYTE_LOWER_A || url_str.getbyte(1) == BYTE_UPPER_A) &&
|
|
1608
|
+
(url_str.getbyte(2) == BYTE_LOWER_T || url_str.getbyte(2) == BYTE_UPPER_T) &&
|
|
1609
|
+
(url_str.getbyte(3) == BYTE_LOWER_A || url_str.getbyte(3) == BYTE_UPPER_A) &&
|
|
1610
|
+
url_str.getbyte(4) == BYTE_COLON
|
|
1611
|
+
needs_resolution = false
|
|
1612
|
+
end
|
|
1371
1613
|
end
|
|
1372
|
-
@pos += 1 unless eof? # Skip semicolon
|
|
1373
|
-
next
|
|
1374
1614
|
end
|
|
1375
|
-
end
|
|
1376
1615
|
|
|
1377
|
-
|
|
1378
|
-
|
|
1616
|
+
if needs_resolution
|
|
1617
|
+
# Resolve relative URL using the resolver proc
|
|
1618
|
+
begin
|
|
1619
|
+
resolved = @_uri_resolver.call(@_base_uri, url_str)
|
|
1620
|
+
result << "'" << resolved << "'"
|
|
1621
|
+
rescue StandardError
|
|
1622
|
+
# If resolution fails, preserve original
|
|
1623
|
+
if quote_char
|
|
1624
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1625
|
+
else
|
|
1626
|
+
result << url_str
|
|
1627
|
+
end
|
|
1628
|
+
end
|
|
1629
|
+
elsif url_str.empty?
|
|
1630
|
+
# Preserve original URL
|
|
1631
|
+
result << "''"
|
|
1632
|
+
elsif quote_char
|
|
1633
|
+
result << quote_char.chr << url_str << quote_char.chr
|
|
1634
|
+
else
|
|
1635
|
+
result << url_str
|
|
1636
|
+
end
|
|
1637
|
+
|
|
1638
|
+
# Skip past closing quote if present
|
|
1639
|
+
pos += 1 if quote_char && pos < len && value.getbyte(pos) == quote_char
|
|
1640
|
+
|
|
1641
|
+
# Skip whitespace before )
|
|
1642
|
+
while pos < len && (value.getbyte(pos) == BYTE_SPACE || value.getbyte(pos) == BYTE_TAB)
|
|
1643
|
+
pos += 1
|
|
1644
|
+
end
|
|
1645
|
+
|
|
1646
|
+
# The ) will be copied in the next iteration or at the end
|
|
1647
|
+
else
|
|
1648
|
+
result << byte.chr
|
|
1649
|
+
pos += 1
|
|
1650
|
+
end
|
|
1379
1651
|
end
|
|
1652
|
+
|
|
1653
|
+
result
|
|
1380
1654
|
end
|
|
1381
1655
|
|
|
1382
1656
|
# Parse a block of declarations given start/end positions
|
|
@@ -1388,7 +1662,7 @@ module Cataract
|
|
|
1388
1662
|
|
|
1389
1663
|
while pos < end_pos
|
|
1390
1664
|
# Skip whitespace
|
|
1391
|
-
while pos < end_pos && whitespace?(@
|
|
1665
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1392
1666
|
pos += 1
|
|
1393
1667
|
end
|
|
1394
1668
|
break if pos >= end_pos
|
|
@@ -1400,5 +1674,115 @@ module Cataract
|
|
|
1400
1674
|
|
|
1401
1675
|
declarations
|
|
1402
1676
|
end
|
|
1677
|
+
|
|
1678
|
+
# Combine parent and child media query parts directly without string building
|
|
1679
|
+
#
|
|
1680
|
+
# The parent's type takes precedence (child type is ignored per CSS spec).
|
|
1681
|
+
#
|
|
1682
|
+
# @param parent_mq [MediaQuery] Parent media query object
|
|
1683
|
+
# @param child_conditions [String|nil] Child conditions (e.g., "(min-width: 500px)")
|
|
1684
|
+
# @return [Array<Symbol, String|nil>] [combined_type, combined_conditions]
|
|
1685
|
+
#
|
|
1686
|
+
# @example
|
|
1687
|
+
# combine_media_query_parts(screen_mq, "(min-width: 500px)") #=> [:screen, "... and (min-width: 500px)"]
|
|
1688
|
+
def combine_media_query_parts(parent_mq, child_conditions)
|
|
1689
|
+
# Type: parent's type wins (outermost type)
|
|
1690
|
+
combined_type = parent_mq.type
|
|
1691
|
+
|
|
1692
|
+
# Conditions: combine parent and child conditions
|
|
1693
|
+
combined_conditions = if parent_mq.conditions && child_conditions
|
|
1694
|
+
"#{parent_mq.conditions} and #{child_conditions}"
|
|
1695
|
+
elsif parent_mq.conditions
|
|
1696
|
+
parent_mq.conditions
|
|
1697
|
+
elsif child_conditions
|
|
1698
|
+
child_conditions
|
|
1699
|
+
end
|
|
1700
|
+
|
|
1701
|
+
[combined_type, combined_conditions]
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
# Parse media query string into type and conditions
|
|
1705
|
+
#
|
|
1706
|
+
# @param query [String] Media query string (e.g., "screen", "screen and (min-width: 768px)")
|
|
1707
|
+
# @return [Array<Symbol, String|nil>] [type, conditions] where type is Symbol, conditions is String or nil
|
|
1708
|
+
#
|
|
1709
|
+
# @example
|
|
1710
|
+
# parse_media_query_parts("screen") #=> [:screen, nil]
|
|
1711
|
+
# parse_media_query_parts("screen and (min-width: 768px)") #=> [:screen, "(min-width: 768px)"]
|
|
1712
|
+
# parse_media_query_parts("(min-width: 500px)") #=> [:all, "(min-width: 500px)"]
|
|
1713
|
+
def parse_media_query_parts(query)
|
|
1714
|
+
i = 0
|
|
1715
|
+
len = query.bytesize
|
|
1716
|
+
|
|
1717
|
+
# Skip leading whitespace
|
|
1718
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1719
|
+
i += 1
|
|
1720
|
+
end
|
|
1721
|
+
|
|
1722
|
+
return [:all, nil] if i >= len
|
|
1723
|
+
|
|
1724
|
+
# Check if starts with '(' - media feature without type (defaults to :all)
|
|
1725
|
+
if query.getbyte(i) == BYTE_LPAREN
|
|
1726
|
+
return [:all, query.byteslice(i, len - i)]
|
|
1727
|
+
end
|
|
1728
|
+
|
|
1729
|
+
# Find first media type word
|
|
1730
|
+
word_start = i
|
|
1731
|
+
while i < len
|
|
1732
|
+
byte = query.getbyte(i)
|
|
1733
|
+
break if whitespace?(byte) || byte == BYTE_LPAREN
|
|
1734
|
+
|
|
1735
|
+
i += 1
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
type = query.byteslice(word_start, i - word_start).to_sym
|
|
1739
|
+
|
|
1740
|
+
# Skip whitespace after type
|
|
1741
|
+
while i < len && whitespace?(query.getbyte(i))
|
|
1742
|
+
i += 1
|
|
1743
|
+
end
|
|
1744
|
+
|
|
1745
|
+
# Check if there's more (conditions)
|
|
1746
|
+
if i >= len
|
|
1747
|
+
return [type, nil]
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
# Look for " and " keyword (case-insensitive)
|
|
1751
|
+
# We need to find "and" as a separate word
|
|
1752
|
+
and_pos = nil
|
|
1753
|
+
check_i = i
|
|
1754
|
+
while check_i < len - 2
|
|
1755
|
+
# Check for 'and' (a=97/65, n=110/78, d=100/68)
|
|
1756
|
+
byte0 = query.getbyte(check_i)
|
|
1757
|
+
byte1 = query.getbyte(check_i + 1)
|
|
1758
|
+
byte2 = query.getbyte(check_i + 2)
|
|
1759
|
+
|
|
1760
|
+
if (byte0 == BYTE_LOWER_A || byte0 == BYTE_UPPER_A) &&
|
|
1761
|
+
(byte1 == BYTE_LOWER_N || byte1 == BYTE_UPPER_N) &&
|
|
1762
|
+
(byte2 == BYTE_LOWER_D || byte2 == BYTE_UPPER_D)
|
|
1763
|
+
# Make sure it's a word boundary (whitespace before and after)
|
|
1764
|
+
before_ok = check_i == 0 || whitespace?(query.getbyte(check_i - 1))
|
|
1765
|
+
after_ok = check_i + 3 >= len || whitespace?(query.getbyte(check_i + 3))
|
|
1766
|
+
if before_ok && after_ok
|
|
1767
|
+
and_pos = check_i
|
|
1768
|
+
break
|
|
1769
|
+
end
|
|
1770
|
+
end
|
|
1771
|
+
check_i += 1
|
|
1772
|
+
end
|
|
1773
|
+
|
|
1774
|
+
if and_pos
|
|
1775
|
+
# Skip past "and " to get conditions
|
|
1776
|
+
conditions_start = and_pos + 3 # skip "and"
|
|
1777
|
+
while conditions_start < len && whitespace?(query.getbyte(conditions_start))
|
|
1778
|
+
conditions_start += 1
|
|
1779
|
+
end
|
|
1780
|
+
conditions = query.byteslice(conditions_start, len - conditions_start)
|
|
1781
|
+
[type, conditions]
|
|
1782
|
+
else
|
|
1783
|
+
# No "and" found - rest is conditions (unusual but possible)
|
|
1784
|
+
[type, query.byteslice(i, len - i)]
|
|
1785
|
+
end
|
|
1786
|
+
end
|
|
1403
1787
|
end
|
|
1404
1788
|
end
|