cataract 0.2.3 → 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 +10 -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 +637 -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,12 +690,12 @@ 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
697
|
# Parse value (read until ';' or '}', but respect quoted strings)
|
|
645
|
-
value_start = @
|
|
698
|
+
value_start = @_pos
|
|
646
699
|
important = false
|
|
647
700
|
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
648
701
|
|
|
@@ -653,9 +706,9 @@ module Cataract
|
|
|
653
706
|
# Inside quoted string - only exit on matching quote
|
|
654
707
|
if byte == in_quote
|
|
655
708
|
in_quote = nil
|
|
656
|
-
elsif byte == BYTE_BACKSLASH && @
|
|
709
|
+
elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
657
710
|
# Skip escaped character
|
|
658
|
-
@
|
|
711
|
+
@_pos += 1
|
|
659
712
|
end
|
|
660
713
|
else
|
|
661
714
|
# Not in quote - check for terminators or quote start
|
|
@@ -666,10 +719,10 @@ module Cataract
|
|
|
666
719
|
end
|
|
667
720
|
end
|
|
668
721
|
|
|
669
|
-
@
|
|
722
|
+
@_pos += 1
|
|
670
723
|
end
|
|
671
724
|
|
|
672
|
-
value = byteslice_encoded(value_start, @
|
|
725
|
+
value = byteslice_encoded(value_start, @_pos - value_start)
|
|
673
726
|
value.strip!
|
|
674
727
|
|
|
675
728
|
# Check for !important (byte-by-byte, no regexp)
|
|
@@ -705,7 +758,10 @@ module Cataract
|
|
|
705
758
|
end
|
|
706
759
|
|
|
707
760
|
# Skip semicolon if present
|
|
708
|
-
@
|
|
761
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
762
|
+
|
|
763
|
+
# Convert relative URLs to absolute if enabled
|
|
764
|
+
value = convert_urls_in_value(value)
|
|
709
765
|
|
|
710
766
|
# Create Declaration struct
|
|
711
767
|
declarations << Declaration.new(property, value, important)
|
|
@@ -717,35 +773,35 @@ module Cataract
|
|
|
717
773
|
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
718
774
|
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
719
775
|
def parse_at_rule
|
|
720
|
-
at_rule_start = @
|
|
721
|
-
@
|
|
776
|
+
at_rule_start = @_pos # Points to '@'
|
|
777
|
+
@_pos += 1 # skip '@'
|
|
722
778
|
|
|
723
779
|
# Find end of at-rule name (stop at whitespace or opening brace)
|
|
724
|
-
name_start = @
|
|
780
|
+
name_start = @_pos
|
|
725
781
|
until eof?
|
|
726
782
|
byte = peek_byte
|
|
727
783
|
break if whitespace?(byte) || byte == BYTE_LBRACE
|
|
728
784
|
|
|
729
|
-
@
|
|
785
|
+
@_pos += 1
|
|
730
786
|
end
|
|
731
787
|
|
|
732
|
-
at_rule_name = byteslice_encoded(name_start, @
|
|
788
|
+
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
733
789
|
|
|
734
790
|
# Handle @charset specially - it's just @charset "value";
|
|
735
791
|
if at_rule_name == 'charset'
|
|
736
792
|
skip_ws_and_comments
|
|
737
793
|
# Read until semicolon
|
|
738
|
-
value_start = @
|
|
794
|
+
value_start = @_pos
|
|
739
795
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
740
|
-
@
|
|
796
|
+
@_pos += 1
|
|
741
797
|
end
|
|
742
798
|
|
|
743
|
-
charset_value = byteslice_encoded(value_start, @
|
|
799
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
744
800
|
charset_value.strip!
|
|
745
801
|
# Remove quotes
|
|
746
802
|
@charset = charset_value.delete('"\'')
|
|
747
803
|
|
|
748
|
-
@
|
|
804
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
749
805
|
return
|
|
750
806
|
end
|
|
751
807
|
|
|
@@ -756,9 +812,9 @@ module Cataract
|
|
|
756
812
|
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
757
813
|
# Skip to semicolon
|
|
758
814
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
759
|
-
@
|
|
815
|
+
@_pos += 1
|
|
760
816
|
end
|
|
761
|
-
@
|
|
817
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
762
818
|
return
|
|
763
819
|
end
|
|
764
820
|
|
|
@@ -773,28 +829,28 @@ module Cataract
|
|
|
773
829
|
|
|
774
830
|
# Skip to opening brace
|
|
775
831
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
776
|
-
@
|
|
832
|
+
@_pos += 1
|
|
777
833
|
end
|
|
778
834
|
|
|
779
835
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
780
836
|
|
|
781
|
-
@
|
|
837
|
+
@_pos += 1 # skip '{'
|
|
782
838
|
|
|
783
839
|
# Find matching closing brace
|
|
784
|
-
block_start = @
|
|
785
|
-
block_end = find_matching_brace(@
|
|
840
|
+
block_start = @_pos
|
|
841
|
+
block_end = find_matching_brace(@_pos)
|
|
786
842
|
|
|
787
843
|
# Check depth before recursing
|
|
788
|
-
if @
|
|
844
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
789
845
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
790
846
|
end
|
|
791
847
|
|
|
792
848
|
# Recursively parse block content (preserve parent media context)
|
|
793
849
|
nested_parser = Parser.new(
|
|
794
850
|
byteslice_encoded(block_start, block_end - block_start),
|
|
795
|
-
parser_options: @
|
|
796
|
-
parent_media_sym: @
|
|
797
|
-
depth: @
|
|
851
|
+
parser_options: @_parser_options,
|
|
852
|
+
parent_media_sym: @_parent_media_sym,
|
|
853
|
+
depth: @_depth + 1
|
|
798
854
|
)
|
|
799
855
|
|
|
800
856
|
nested_result = nested_parser.parse
|
|
@@ -804,33 +860,29 @@ module Cataract
|
|
|
804
860
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
805
861
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
806
862
|
new_list_id = list_id + list_id_offset
|
|
807
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
863
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
808
864
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
809
865
|
end
|
|
810
866
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
811
867
|
end
|
|
812
868
|
|
|
813
|
-
#
|
|
814
|
-
|
|
815
|
-
@_media_index[media] ||= []
|
|
816
|
-
# Use each + << instead of concat + map (1.20x faster for small arrays)
|
|
817
|
-
rule_ids.each { |rid| @_media_index[media] << (@rule_id_counter + rid) }
|
|
818
|
-
end
|
|
869
|
+
# NOTE: We no longer build media_index during parse
|
|
870
|
+
# It will be built from MediaQuery objects after import resolution
|
|
819
871
|
|
|
820
872
|
# Add nested rules to main rules array
|
|
821
873
|
nested_result[:rules].each do |rule|
|
|
822
|
-
rule.id = @
|
|
874
|
+
rule.id = @_rule_id_counter
|
|
823
875
|
# Update selector_list_id if applicable
|
|
824
876
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
825
877
|
rule.selector_list_id += list_id_offset
|
|
826
878
|
end
|
|
827
|
-
@
|
|
879
|
+
@_rule_id_counter += 1
|
|
828
880
|
@rules << rule
|
|
829
881
|
end
|
|
830
882
|
|
|
831
883
|
# Move position past the closing brace
|
|
832
|
-
@
|
|
833
|
-
@
|
|
884
|
+
@_pos = block_end
|
|
885
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
834
886
|
|
|
835
887
|
return
|
|
836
888
|
end
|
|
@@ -840,16 +892,16 @@ module Cataract
|
|
|
840
892
|
skip_ws_and_comments
|
|
841
893
|
|
|
842
894
|
# Find media query (up to opening brace)
|
|
843
|
-
mq_start = @
|
|
895
|
+
mq_start = @_pos
|
|
844
896
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
845
|
-
@
|
|
897
|
+
@_pos += 1
|
|
846
898
|
end
|
|
847
899
|
|
|
848
900
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
849
901
|
|
|
850
|
-
mq_end = @
|
|
902
|
+
mq_end = @_pos
|
|
851
903
|
# Trim trailing whitespace
|
|
852
|
-
while mq_end > mq_start && whitespace?(@
|
|
904
|
+
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
853
905
|
mq_end -= 1
|
|
854
906
|
end
|
|
855
907
|
|
|
@@ -858,34 +910,65 @@ module Cataract
|
|
|
858
910
|
child_media_string.strip!
|
|
859
911
|
child_media_sym = child_media_string.to_sym
|
|
860
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
|
+
|
|
861
936
|
# Combine with parent media context
|
|
862
|
-
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
|
|
863
944
|
|
|
864
945
|
# Check media query limit
|
|
865
|
-
unless @
|
|
866
|
-
@
|
|
867
|
-
if @
|
|
946
|
+
unless @media_index.key?(combined_media_sym)
|
|
947
|
+
@_media_query_count += 1
|
|
948
|
+
if @_media_query_count > MAX_MEDIA_QUERIES
|
|
868
949
|
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
869
950
|
end
|
|
870
951
|
end
|
|
871
952
|
|
|
872
|
-
@
|
|
953
|
+
@_pos += 1 # skip '{'
|
|
873
954
|
|
|
874
955
|
# Find matching closing brace
|
|
875
|
-
block_start = @
|
|
876
|
-
block_end = find_matching_brace(@
|
|
956
|
+
block_start = @_pos
|
|
957
|
+
block_end = find_matching_brace(@_pos)
|
|
877
958
|
|
|
878
959
|
# Check depth before recursing
|
|
879
|
-
if @
|
|
960
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
880
961
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
881
962
|
end
|
|
882
963
|
|
|
883
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
|
|
884
967
|
nested_parser = Parser.new(
|
|
885
968
|
byteslice_encoded(block_start, block_end - block_start),
|
|
886
|
-
parser_options: @
|
|
969
|
+
parser_options: @_parser_options,
|
|
887
970
|
parent_media_sym: combined_media_sym,
|
|
888
|
-
depth: @
|
|
971
|
+
depth: @_depth + 1
|
|
889
972
|
)
|
|
890
973
|
|
|
891
974
|
nested_result = nested_parser.parse
|
|
@@ -895,50 +978,84 @@ module Cataract
|
|
|
895
978
|
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
896
979
|
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
897
980
|
new_list_id = list_id + list_id_offset
|
|
898
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @
|
|
981
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
899
982
|
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
900
983
|
end
|
|
901
984
|
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
902
985
|
end
|
|
903
986
|
|
|
904
|
-
# Merge nested
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
909
996
|
end
|
|
910
997
|
|
|
911
|
-
#
|
|
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
|
|
1007
|
+
end
|
|
1008
|
+
|
|
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
|
|
912
1014
|
nested_result[:rules].each do |rule|
|
|
913
|
-
rule.id = @
|
|
1015
|
+
rule.id = @_rule_id_counter
|
|
914
1016
|
# Update selector_list_id if applicable
|
|
915
1017
|
if rule.is_a?(Rule) && rule.selector_list_id
|
|
916
1018
|
rule.selector_list_id += list_id_offset
|
|
917
1019
|
end
|
|
918
1020
|
|
|
919
|
-
#
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
@
|
|
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
|
|
928
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
|
|
929
1047
|
end
|
|
930
1048
|
|
|
931
|
-
#
|
|
932
|
-
|
|
933
|
-
@_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
|
|
934
1051
|
|
|
935
|
-
@
|
|
1052
|
+
@_rule_id_counter += 1
|
|
936
1053
|
@rules << rule
|
|
937
1054
|
end
|
|
938
1055
|
|
|
939
1056
|
# Move position past the closing brace
|
|
940
|
-
@
|
|
941
|
-
@
|
|
1057
|
+
@_pos = block_end
|
|
1058
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
942
1059
|
|
|
943
1060
|
return
|
|
944
1061
|
end
|
|
@@ -954,26 +1071,26 @@ module Cataract
|
|
|
954
1071
|
|
|
955
1072
|
# Skip to opening brace
|
|
956
1073
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
957
|
-
@
|
|
1074
|
+
@_pos += 1
|
|
958
1075
|
end
|
|
959
1076
|
|
|
960
1077
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
961
1078
|
|
|
962
|
-
selector_end = @
|
|
1079
|
+
selector_end = @_pos
|
|
963
1080
|
# Trim trailing whitespace
|
|
964
|
-
while selector_end > selector_start && whitespace?(@
|
|
1081
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
965
1082
|
selector_end -= 1
|
|
966
1083
|
end
|
|
967
1084
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
968
1085
|
|
|
969
|
-
@
|
|
1086
|
+
@_pos += 1 # skip '{'
|
|
970
1087
|
|
|
971
1088
|
# Find matching closing brace
|
|
972
|
-
block_start = @
|
|
973
|
-
block_end = find_matching_brace(@
|
|
1089
|
+
block_start = @_pos
|
|
1090
|
+
block_end = find_matching_brace(@_pos)
|
|
974
1091
|
|
|
975
1092
|
# Check depth before recursing
|
|
976
|
-
if @
|
|
1093
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
977
1094
|
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
978
1095
|
end
|
|
979
1096
|
|
|
@@ -981,23 +1098,23 @@ module Cataract
|
|
|
981
1098
|
# Create a nested parser context
|
|
982
1099
|
nested_parser = Parser.new(
|
|
983
1100
|
byteslice_encoded(block_start, block_end - block_start),
|
|
984
|
-
parser_options: @
|
|
985
|
-
depth: @
|
|
1101
|
+
parser_options: @_parser_options,
|
|
1102
|
+
depth: @_depth + 1
|
|
986
1103
|
)
|
|
987
1104
|
nested_result = nested_parser.parse
|
|
988
1105
|
content = nested_result[:rules]
|
|
989
1106
|
|
|
990
1107
|
# Move position past the closing brace
|
|
991
|
-
@
|
|
1108
|
+
@_pos = block_end
|
|
992
1109
|
# The closing brace should be at block_end
|
|
993
|
-
@
|
|
1110
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
994
1111
|
|
|
995
1112
|
# Get rule ID and increment
|
|
996
|
-
rule_id = @
|
|
997
|
-
@
|
|
1113
|
+
rule_id = @_rule_id_counter
|
|
1114
|
+
@_rule_id_counter += 1
|
|
998
1115
|
|
|
999
1116
|
# Create AtRule with nested rules
|
|
1000
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1117
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1001
1118
|
@rules << at_rule
|
|
1002
1119
|
|
|
1003
1120
|
return
|
|
@@ -1010,38 +1127,38 @@ module Cataract
|
|
|
1010
1127
|
|
|
1011
1128
|
# Skip to opening brace
|
|
1012
1129
|
while !eof? && peek_byte != BYTE_LBRACE
|
|
1013
|
-
@
|
|
1130
|
+
@_pos += 1
|
|
1014
1131
|
end
|
|
1015
1132
|
|
|
1016
1133
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1017
1134
|
|
|
1018
|
-
selector_end = @
|
|
1135
|
+
selector_end = @_pos
|
|
1019
1136
|
# Trim trailing whitespace
|
|
1020
|
-
while selector_end > selector_start && whitespace?(@
|
|
1137
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1021
1138
|
selector_end -= 1
|
|
1022
1139
|
end
|
|
1023
1140
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1024
1141
|
|
|
1025
|
-
@
|
|
1142
|
+
@_pos += 1 # skip '{'
|
|
1026
1143
|
|
|
1027
1144
|
# Find matching closing brace
|
|
1028
|
-
decl_start = @
|
|
1029
|
-
decl_end = find_matching_brace(@
|
|
1145
|
+
decl_start = @_pos
|
|
1146
|
+
decl_end = find_matching_brace(@_pos)
|
|
1030
1147
|
|
|
1031
1148
|
# Parse declarations
|
|
1032
1149
|
content = parse_declarations_block(decl_start, decl_end)
|
|
1033
1150
|
|
|
1034
1151
|
# Move position past the closing brace
|
|
1035
|
-
@
|
|
1152
|
+
@_pos = decl_end
|
|
1036
1153
|
# The closing brace should be at decl_end
|
|
1037
|
-
@
|
|
1154
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1038
1155
|
|
|
1039
1156
|
# Get rule ID and increment
|
|
1040
|
-
rule_id = @
|
|
1041
|
-
@
|
|
1157
|
+
rule_id = @_rule_id_counter
|
|
1158
|
+
@_rule_id_counter += 1
|
|
1042
1159
|
|
|
1043
1160
|
# Create AtRule with declarations
|
|
1044
|
-
at_rule = AtRule.new(rule_id, selector, content, nil)
|
|
1161
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1045
1162
|
@rules << at_rule
|
|
1046
1163
|
|
|
1047
1164
|
return
|
|
@@ -1053,26 +1170,26 @@ module Cataract
|
|
|
1053
1170
|
|
|
1054
1171
|
# Skip to opening brace
|
|
1055
1172
|
until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
1056
|
-
@
|
|
1173
|
+
@_pos += 1
|
|
1057
1174
|
end
|
|
1058
1175
|
|
|
1059
1176
|
return if eof? || peek_byte != BYTE_LBRACE
|
|
1060
1177
|
|
|
1061
|
-
selector_end = @
|
|
1178
|
+
selector_end = @_pos
|
|
1062
1179
|
# Trim trailing whitespace
|
|
1063
|
-
while selector_end > selector_start && whitespace?(@
|
|
1180
|
+
while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
1064
1181
|
selector_end -= 1
|
|
1065
1182
|
end
|
|
1066
1183
|
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1067
1184
|
|
|
1068
|
-
@
|
|
1185
|
+
@_pos += 1 # skip '{'
|
|
1069
1186
|
|
|
1070
1187
|
# Parse declarations
|
|
1071
1188
|
declarations = parse_declarations
|
|
1072
1189
|
|
|
1073
1190
|
# Create Rule with declarations
|
|
1074
1191
|
rule = Rule.new(
|
|
1075
|
-
@
|
|
1192
|
+
@_rule_id_counter, # id
|
|
1076
1193
|
selector, # selector (e.g., "@property --main-color")
|
|
1077
1194
|
declarations, # declarations
|
|
1078
1195
|
nil, # specificity
|
|
@@ -1081,7 +1198,7 @@ module Cataract
|
|
|
1081
1198
|
)
|
|
1082
1199
|
|
|
1083
1200
|
@rules << rule
|
|
1084
|
-
@
|
|
1201
|
+
@_rule_id_counter += 1
|
|
1085
1202
|
end
|
|
1086
1203
|
|
|
1087
1204
|
# Check if block contains nested selectors vs just declarations
|
|
@@ -1091,16 +1208,16 @@ module Cataract
|
|
|
1091
1208
|
|
|
1092
1209
|
while pos < end_pos
|
|
1093
1210
|
# Skip whitespace
|
|
1094
|
-
while pos < end_pos && whitespace?(@
|
|
1211
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1095
1212
|
pos += 1
|
|
1096
1213
|
end
|
|
1097
1214
|
break if pos >= end_pos
|
|
1098
1215
|
|
|
1099
1216
|
# Skip comments
|
|
1100
|
-
if pos + 1 < end_pos && @
|
|
1217
|
+
if pos + 1 < end_pos && @_css.getbyte(pos) == BYTE_SLASH && @_css.getbyte(pos + 1) == BYTE_STAR
|
|
1101
1218
|
pos += 2
|
|
1102
1219
|
while pos + 1 < end_pos
|
|
1103
|
-
if @
|
|
1220
|
+
if @_css.getbyte(pos) == BYTE_STAR && @_css.getbyte(pos + 1) == BYTE_SLASH
|
|
1104
1221
|
pos += 2
|
|
1105
1222
|
break
|
|
1106
1223
|
end
|
|
@@ -1110,24 +1227,24 @@ module Cataract
|
|
|
1110
1227
|
end
|
|
1111
1228
|
|
|
1112
1229
|
# Check for nested selector indicators
|
|
1113
|
-
byte = @
|
|
1230
|
+
byte = @_css.getbyte(pos)
|
|
1114
1231
|
if byte == BYTE_AMPERSAND || byte == BYTE_DOT || byte == BYTE_HASH ||
|
|
1115
1232
|
byte == BYTE_LBRACKET || byte == BYTE_COLON || byte == BYTE_ASTERISK ||
|
|
1116
1233
|
byte == BYTE_GT || byte == BYTE_PLUS || byte == BYTE_TILDE
|
|
1117
1234
|
# Look ahead - if followed by {, it's likely a nested selector
|
|
1118
1235
|
lookahead = pos + 1
|
|
1119
|
-
while lookahead < end_pos && @
|
|
1120
|
-
@
|
|
1236
|
+
while lookahead < end_pos && @_css.getbyte(lookahead) != BYTE_LBRACE &&
|
|
1237
|
+
@_css.getbyte(lookahead) != BYTE_SEMICOLON && @_css.getbyte(lookahead) != BYTE_NEWLINE
|
|
1121
1238
|
lookahead += 1
|
|
1122
1239
|
end
|
|
1123
|
-
return true if lookahead < end_pos && @
|
|
1240
|
+
return true if lookahead < end_pos && @_css.getbyte(lookahead) == BYTE_LBRACE
|
|
1124
1241
|
end
|
|
1125
1242
|
|
|
1126
1243
|
# Check for @media, @supports, etc nested inside
|
|
1127
1244
|
return true if byte == BYTE_AT
|
|
1128
1245
|
|
|
1129
1246
|
# Skip to next line or semicolon
|
|
1130
|
-
while pos < end_pos && @
|
|
1247
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_NEWLINE
|
|
1131
1248
|
pos += 1
|
|
1132
1249
|
end
|
|
1133
1250
|
pos += 1 if pos < end_pos
|
|
@@ -1263,10 +1380,10 @@ module Cataract
|
|
|
1263
1380
|
# Skip to next semicolon or closing brace (error recovery)
|
|
1264
1381
|
def skip_to_semicolon_or_brace
|
|
1265
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
|
|
1266
|
-
@
|
|
1383
|
+
@_pos += 1
|
|
1267
1384
|
end
|
|
1268
1385
|
|
|
1269
|
-
@
|
|
1386
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1270
1387
|
end
|
|
1271
1388
|
|
|
1272
1389
|
# Parse an @import statement
|
|
@@ -1277,9 +1394,9 @@ module Cataract
|
|
|
1277
1394
|
|
|
1278
1395
|
# Check for optional url(
|
|
1279
1396
|
has_url_function = false
|
|
1280
|
-
if @
|
|
1397
|
+
if @_pos + 4 <= @_len && match_ascii_ci?(@_css, @_pos, 'url(')
|
|
1281
1398
|
has_url_function = true
|
|
1282
|
-
@
|
|
1399
|
+
@_pos += 4
|
|
1283
1400
|
skip_ws_and_comments
|
|
1284
1401
|
end
|
|
1285
1402
|
|
|
@@ -1288,24 +1405,24 @@ module Cataract
|
|
|
1288
1405
|
if eof? || (byte != BYTE_DQUOTE && byte != BYTE_SQUOTE)
|
|
1289
1406
|
# Invalid @import, skip to semicolon
|
|
1290
1407
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1291
|
-
@
|
|
1408
|
+
@_pos += 1
|
|
1292
1409
|
end
|
|
1293
|
-
@
|
|
1410
|
+
@_pos += 1 unless eof?
|
|
1294
1411
|
return
|
|
1295
1412
|
end
|
|
1296
1413
|
|
|
1297
1414
|
quote_char = byte
|
|
1298
|
-
@
|
|
1415
|
+
@_pos += 1 # Skip opening quote
|
|
1299
1416
|
|
|
1300
|
-
url_start = @
|
|
1417
|
+
url_start = @_pos
|
|
1301
1418
|
|
|
1302
1419
|
# Find closing quote (handle escaped quotes)
|
|
1303
1420
|
while !eof? && peek_byte != quote_char
|
|
1304
|
-
@
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1421
|
+
@_pos += if peek_byte == BYTE_BACKSLASH && @_pos + 1 < @_len
|
|
1422
|
+
2 # Skip escaped character
|
|
1423
|
+
else
|
|
1424
|
+
1
|
|
1425
|
+
end
|
|
1309
1426
|
end
|
|
1310
1427
|
|
|
1311
1428
|
if eof?
|
|
@@ -1313,87 +1430,227 @@ module Cataract
|
|
|
1313
1430
|
return
|
|
1314
1431
|
end
|
|
1315
1432
|
|
|
1316
|
-
url = byteslice_encoded(url_start, @
|
|
1317
|
-
@
|
|
1433
|
+
url = byteslice_encoded(url_start, @_pos - url_start)
|
|
1434
|
+
@_pos += 1 # Skip closing quote
|
|
1318
1435
|
|
|
1319
1436
|
# Skip closing paren if we had url(
|
|
1320
1437
|
if has_url_function
|
|
1321
1438
|
skip_ws_and_comments
|
|
1322
|
-
@
|
|
1439
|
+
@_pos += 1 if peek_byte == BYTE_RPAREN
|
|
1323
1440
|
end
|
|
1324
1441
|
|
|
1325
1442
|
skip_ws_and_comments
|
|
1326
1443
|
|
|
1327
1444
|
# Check for optional media query (everything until semicolon)
|
|
1328
|
-
|
|
1445
|
+
media_string = nil
|
|
1446
|
+
media_query_id = nil
|
|
1329
1447
|
if !eof? && peek_byte != BYTE_SEMICOLON
|
|
1330
|
-
media_start = @
|
|
1448
|
+
media_start = @_pos
|
|
1331
1449
|
|
|
1332
1450
|
# Find semicolon
|
|
1333
1451
|
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1334
|
-
@
|
|
1452
|
+
@_pos += 1
|
|
1335
1453
|
end
|
|
1336
1454
|
|
|
1337
|
-
media_end = @
|
|
1455
|
+
media_end = @_pos
|
|
1338
1456
|
|
|
1339
1457
|
# Trim trailing whitespace from media query
|
|
1340
|
-
while media_end > media_start && whitespace?(@
|
|
1458
|
+
while media_end > media_start && whitespace?(@_css.getbyte(media_end - 1))
|
|
1341
1459
|
media_end -= 1
|
|
1342
1460
|
end
|
|
1343
1461
|
|
|
1344
1462
|
if media_end > media_start
|
|
1345
|
-
|
|
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
|
|
1346
1512
|
end
|
|
1347
1513
|
end
|
|
1348
1514
|
|
|
1349
1515
|
# Skip semicolon
|
|
1350
|
-
@
|
|
1516
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1351
1517
|
|
|
1352
1518
|
# Create ImportStatement (resolved: false by default)
|
|
1353
|
-
import_stmt = ImportStatement.new(@
|
|
1519
|
+
import_stmt = ImportStatement.new(@_rule_id_counter, url, media_string, media_query_id, false)
|
|
1354
1520
|
@imports << import_stmt
|
|
1355
|
-
@
|
|
1521
|
+
@_rule_id_counter += 1
|
|
1356
1522
|
end
|
|
1357
1523
|
|
|
1358
|
-
#
|
|
1359
|
-
#
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
|
1367
1553
|
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
|
1375
1580
|
end
|
|
1376
|
-
@pos += 1
|
|
1377
1581
|
end
|
|
1378
|
-
next
|
|
1379
|
-
end
|
|
1380
1582
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
# Check
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
|
1388
1613
|
end
|
|
1389
|
-
@pos += 1 unless eof? # Skip semicolon
|
|
1390
|
-
next
|
|
1391
1614
|
end
|
|
1392
|
-
end
|
|
1393
1615
|
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
|
1396
1651
|
end
|
|
1652
|
+
|
|
1653
|
+
result
|
|
1397
1654
|
end
|
|
1398
1655
|
|
|
1399
1656
|
# Parse a block of declarations given start/end positions
|
|
@@ -1405,7 +1662,7 @@ module Cataract
|
|
|
1405
1662
|
|
|
1406
1663
|
while pos < end_pos
|
|
1407
1664
|
# Skip whitespace
|
|
1408
|
-
while pos < end_pos && whitespace?(@
|
|
1665
|
+
while pos < end_pos && whitespace?(@_css.getbyte(pos))
|
|
1409
1666
|
pos += 1
|
|
1410
1667
|
end
|
|
1411
1668
|
break if pos >= end_pos
|
|
@@ -1417,5 +1674,115 @@ module Cataract
|
|
|
1417
1674
|
|
|
1418
1675
|
declarations
|
|
1419
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
|
|
1420
1787
|
end
|
|
1421
1788
|
end
|