cataract 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +6 -6
- data/.gitignore +17 -3
- data/.rubocop.yml +1 -0
- data/BENCHMARKS.md +24 -6
- data/CHANGELOG.md +29 -1
- data/Gemfile +2 -2
- data/examples/css_analyzer/analyzer.rb +9 -3
- data/examples/css_analyzer/analyzers/base.rb +1 -1
- data/ext/cataract/cataract.c +251 -458
- data/ext/cataract/cataract.h +127 -16
- data/ext/cataract/css_parser.c +1029 -700
- data/ext/cataract/extconf.rb +1 -2
- data/ext/cataract/flatten.c +279 -991
- data/ext/cataract/shorthand_expander.c +80 -102
- data/ext/cataract_color/color_conversion.c +1 -1
- data/lib/cataract/declarations.rb +13 -7
- data/lib/cataract/error.rb +49 -0
- data/lib/cataract/import_resolver.rb +36 -16
- data/lib/cataract/pure/byte_constants.rb +4 -1
- data/lib/cataract/pure/flatten.rb +143 -145
- data/lib/cataract/pure/parser.rb +623 -388
- data/lib/cataract/pure/serializer.rb +109 -104
- data/lib/cataract/pure/specificity.rb +112 -156
- data/lib/cataract/pure.rb +1 -6
- data/lib/cataract/stylesheet.rb +480 -410
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +1 -0
- metadata +3 -13
- data/ext/cataract/import_scanner.c +0 -174
- data/ext/cataract_old/cataract.c +0 -393
- data/ext/cataract_old/cataract.h +0 -250
- data/ext/cataract_old/css_parser.c +0 -933
- data/ext/cataract_old/extconf.rb +0 -67
- data/ext/cataract_old/import_scanner.c +0 -174
- data/ext/cataract_old/merge.c +0 -776
- data/ext/cataract_old/shorthand_expander.c +0 -902
- data/ext/cataract_old/specificity.c +0 -213
- data/ext/cataract_old/stylesheet.c +0 -290
- data/ext/cataract_old/value_splitter.c +0 -116
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -23,10 +23,6 @@ module Cataract
|
|
|
23
23
|
# Maximum media queries (prevent symbol table exhaustion)
|
|
24
24
|
MAX_MEDIA_QUERIES = 1000
|
|
25
25
|
|
|
26
|
-
# Maximum property name/value lengths
|
|
27
|
-
MAX_PROPERTY_NAME_LENGTH = 256
|
|
28
|
-
MAX_PROPERTY_VALUE_LENGTH = 32_768
|
|
29
|
-
|
|
30
26
|
AT_RULE_TYPES = %w[supports layer container scope].freeze
|
|
31
27
|
|
|
32
28
|
# Extract substring and force specified encoding
|
|
@@ -64,6 +60,9 @@ module Cataract
|
|
|
64
60
|
end
|
|
65
61
|
|
|
66
62
|
def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
|
|
63
|
+
# Type validation
|
|
64
|
+
raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
|
|
65
|
+
|
|
67
66
|
# Private: Internal parsing state
|
|
68
67
|
@_css = css_string.dup.freeze
|
|
69
68
|
@_pos = 0
|
|
@@ -77,7 +76,8 @@ module Cataract
|
|
|
77
76
|
selector_lists: true,
|
|
78
77
|
base_uri: nil,
|
|
79
78
|
absolute_paths: false,
|
|
80
|
-
uri_resolver: nil
|
|
79
|
+
uri_resolver: nil,
|
|
80
|
+
raise_parse_errors: false
|
|
81
81
|
}.merge(parser_options)
|
|
82
82
|
|
|
83
83
|
# Private: Extract options to ivars to avoid repeated hash lookups in hot path
|
|
@@ -86,6 +86,34 @@ module Cataract
|
|
|
86
86
|
@_absolute_paths = @_parser_options[:absolute_paths]
|
|
87
87
|
@_uri_resolver = @_parser_options[:uri_resolver] || Cataract::DEFAULT_URI_RESOLVER
|
|
88
88
|
|
|
89
|
+
# Parse error handling options - extract to ivars for hot path performance
|
|
90
|
+
@_raise_parse_errors = @_parser_options[:raise_parse_errors]
|
|
91
|
+
if @_raise_parse_errors.is_a?(Hash)
|
|
92
|
+
# Granular control - default all to false (opt-in)
|
|
93
|
+
@_check_empty_values = @_raise_parse_errors[:empty_values] || false
|
|
94
|
+
@_check_malformed_declarations = @_raise_parse_errors[:malformed_declarations] || false
|
|
95
|
+
@_check_invalid_selectors = @_raise_parse_errors[:invalid_selectors] || false
|
|
96
|
+
@_check_invalid_selector_syntax = @_raise_parse_errors[:invalid_selector_syntax] || false
|
|
97
|
+
@_check_malformed_at_rules = @_raise_parse_errors[:malformed_at_rules] || false
|
|
98
|
+
@_check_unclosed_blocks = @_raise_parse_errors[:unclosed_blocks] || false
|
|
99
|
+
elsif @_raise_parse_errors == true
|
|
100
|
+
# Enable all error checks
|
|
101
|
+
@_check_empty_values = true
|
|
102
|
+
@_check_malformed_declarations = true
|
|
103
|
+
@_check_invalid_selectors = true
|
|
104
|
+
@_check_invalid_selector_syntax = true
|
|
105
|
+
@_check_malformed_at_rules = true
|
|
106
|
+
@_check_unclosed_blocks = true
|
|
107
|
+
else
|
|
108
|
+
# Disabled
|
|
109
|
+
@_check_empty_values = false
|
|
110
|
+
@_check_malformed_declarations = false
|
|
111
|
+
@_check_invalid_selectors = false
|
|
112
|
+
@_check_invalid_selector_syntax = false
|
|
113
|
+
@_check_malformed_at_rules = false
|
|
114
|
+
@_check_unclosed_blocks = false
|
|
115
|
+
end
|
|
116
|
+
|
|
89
117
|
# Private: Internal counters
|
|
90
118
|
@_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
|
|
91
119
|
@_next_selector_list_id = 0 # Counter for selector list IDs
|
|
@@ -139,10 +167,9 @@ module Cataract
|
|
|
139
167
|
if has_nested_selectors?(decl_start, decl_end)
|
|
140
168
|
# NESTED PATH: Parse mixed declarations + nested rules
|
|
141
169
|
# Split comma-separated selectors and parse each one
|
|
142
|
-
selectors = selector
|
|
170
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
143
171
|
|
|
144
172
|
selectors.each do |individual_selector|
|
|
145
|
-
individual_selector.strip!
|
|
146
173
|
next if individual_selector.empty?
|
|
147
174
|
|
|
148
175
|
# Get rule ID for this selector
|
|
@@ -181,7 +208,7 @@ module Cataract
|
|
|
181
208
|
declarations = parse_declarations
|
|
182
209
|
|
|
183
210
|
# Split comma-separated selectors into individual rules
|
|
184
|
-
selectors = selector
|
|
211
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
185
212
|
|
|
186
213
|
# Determine if we should track this as a selector list
|
|
187
214
|
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
@@ -193,7 +220,6 @@ module Cataract
|
|
|
193
220
|
end
|
|
194
221
|
|
|
195
222
|
selectors.each do |individual_selector|
|
|
196
|
-
individual_selector.strip!
|
|
197
223
|
next if individual_selector.empty?
|
|
198
224
|
|
|
199
225
|
rule_id = @_rule_id_counter
|
|
@@ -241,6 +267,30 @@ module Cataract
|
|
|
241
267
|
|
|
242
268
|
private
|
|
243
269
|
|
|
270
|
+
# Split a comma-separated selector list into individual selector strings,
|
|
271
|
+
# stripping whitespace from each and validating (in strict mode) that
|
|
272
|
+
# none are empty. Empty entries are left in the returned array - callers
|
|
273
|
+
# skip them - since the emptiness check needs the original
|
|
274
|
+
# comma-separated count (selectors.size > 1) to know whether this is
|
|
275
|
+
# really a list or just a single selector, which callers no longer have
|
|
276
|
+
# once they've filtered.
|
|
277
|
+
#
|
|
278
|
+
# @param selector [String] Raw selector text (already trimmed as a whole)
|
|
279
|
+
# @param pos [Integer] Position to report in a raised ParseError
|
|
280
|
+
# @return [Array<String>] Individual selectors, stripped
|
|
281
|
+
def split_and_validate_selectors(selector, pos)
|
|
282
|
+
selectors = selector.split(',')
|
|
283
|
+
selectors.each do |individual_selector|
|
|
284
|
+
individual_selector.strip!
|
|
285
|
+
|
|
286
|
+
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
287
|
+
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
288
|
+
css: @_css, pos: pos, type: :invalid_selector_syntax)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
selectors
|
|
292
|
+
end
|
|
293
|
+
|
|
244
294
|
# Check if we're at end of input
|
|
245
295
|
def eof?
|
|
246
296
|
@_pos >= @_len
|
|
@@ -303,20 +353,119 @@ module Cataract
|
|
|
303
353
|
end until @_pos == old_pos # No progress made # rubocop:disable Lint/Loop
|
|
304
354
|
end
|
|
305
355
|
|
|
356
|
+
# Check if a selector contains only valid CSS selector characters and sequences
|
|
357
|
+
# Returns true if valid, false if invalid
|
|
358
|
+
# Valid characters: a-z A-Z 0-9 - _ . # [ ] : * > + ~ ( ) ' " = ^ $ | \ & % / whitespace
|
|
359
|
+
def valid_selector_syntax?(selector_text)
|
|
360
|
+
i = 0
|
|
361
|
+
len = selector_text.bytesize
|
|
362
|
+
|
|
363
|
+
while i < len
|
|
364
|
+
byte = selector_text.getbyte(i)
|
|
365
|
+
|
|
366
|
+
# Check for invalid character sequences
|
|
367
|
+
if i + 1 < len
|
|
368
|
+
next_byte = selector_text.getbyte(i + 1)
|
|
369
|
+
# Double dot (..) is invalid
|
|
370
|
+
return false if byte == BYTE_DOT && next_byte == BYTE_DOT
|
|
371
|
+
# Double hash (##) is invalid
|
|
372
|
+
return false if byte == BYTE_HASH && next_byte == BYTE_HASH
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Alphanumeric
|
|
376
|
+
if (byte >= BYTE_LOWER_A && byte <= BYTE_LOWER_Z) || (byte >= BYTE_UPPER_A && byte <= BYTE_UPPER_Z) || (byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9)
|
|
377
|
+
i += 1
|
|
378
|
+
next
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Whitespace
|
|
382
|
+
if byte == BYTE_SPACE || byte == BYTE_TAB || byte == BYTE_NEWLINE || byte == BYTE_CR
|
|
383
|
+
i += 1
|
|
384
|
+
next
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Valid CSS selector special characters
|
|
388
|
+
case byte
|
|
389
|
+
when BYTE_HYPHEN, BYTE_UNDERSCORE, BYTE_DOT, BYTE_HASH, BYTE_LBRACKET, BYTE_RBRACKET,
|
|
390
|
+
BYTE_COLON, BYTE_ASTERISK, BYTE_GT, BYTE_PLUS, BYTE_TILDE, BYTE_LPAREN, BYTE_RPAREN,
|
|
391
|
+
BYTE_SQUOTE, BYTE_DQUOTE, BYTE_EQUALS, BYTE_CARET, BYTE_DOLLAR,
|
|
392
|
+
BYTE_PIPE, BYTE_BACKSLASH, BYTE_AMPERSAND, BYTE_PERCENT, BYTE_SLASH, BYTE_BANG,
|
|
393
|
+
BYTE_COMMA
|
|
394
|
+
i += 1
|
|
395
|
+
else
|
|
396
|
+
# Invalid character found
|
|
397
|
+
return false
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
true
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Detect and strip a trailing '!important' marker from an already-extracted,
|
|
405
|
+
# already-right-trimmed declaration value, in place. Shared by
|
|
406
|
+
# parse_single_declaration and parse_declarations so both code paths agree
|
|
407
|
+
# on what counts as important.
|
|
408
|
+
#
|
|
409
|
+
# The CSS2.1 grammar defines the IMPORTANT_SYM lexical token as:
|
|
410
|
+
# "!"({w}|{comment})*{I}{M}{P}{O}{R}{T}{A}{N}{T}
|
|
411
|
+
# (https://www.w3.org/TR/CSS2/grammar.html) - i.e. zero or more whitespace
|
|
412
|
+
# tokens are allowed between '!' and 'important'.
|
|
413
|
+
#
|
|
414
|
+
# Mutates value in place (via slice!) instead of returning a new
|
|
415
|
+
# [value, important] tuple, so the common case (no !important present)
|
|
416
|
+
# allocates nothing beyond the two getbyte/compare checks below.
|
|
417
|
+
#
|
|
418
|
+
# @param value [String] already-extracted, right-trimmed declaration value (mutated in place)
|
|
419
|
+
# @return [Boolean] whether an important marker was found and stripped
|
|
420
|
+
def extract_important!(value) # rubocop:disable Naming/PredicateMethod
|
|
421
|
+
return false if value.bytesize < 10
|
|
422
|
+
|
|
423
|
+
i = value.bytesize - 1
|
|
424
|
+
# Skip trailing whitespace
|
|
425
|
+
while i >= 0 && whitespace?(value.getbyte(i))
|
|
426
|
+
i -= 1
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Check for 'important' (9 chars)
|
|
430
|
+
return false if i < 8 || value[(i - 8), 9] != 'important'
|
|
431
|
+
|
|
432
|
+
i -= 9
|
|
433
|
+
# Skip whitespace between '!' and 'important'
|
|
434
|
+
while i >= 0 && whitespace?(value.getbyte(i))
|
|
435
|
+
i -= 1
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Check for '!'
|
|
439
|
+
return false unless i >= 0 && value.getbyte(i) == BYTE_BANG
|
|
440
|
+
|
|
441
|
+
# Remove everything from '!' onwards, in place
|
|
442
|
+
value.slice!(i..-1)
|
|
443
|
+
value.strip!
|
|
444
|
+
true
|
|
445
|
+
end
|
|
446
|
+
|
|
306
447
|
# Parse a single CSS declaration (property: value)
|
|
307
448
|
#
|
|
308
449
|
# Performance-critical helper that parses one declaration.
|
|
309
|
-
# Shared by parse_mixed_block
|
|
450
|
+
# Shared by parse_mixed_block and parse_declarations_block.
|
|
310
451
|
#
|
|
311
452
|
# @param pos [Integer] Current position in CSS string
|
|
312
453
|
# @param end_pos [Integer] End position (boundary for parsing)
|
|
313
454
|
# @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
|
|
314
455
|
# @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
|
|
315
456
|
def parse_single_declaration(pos, end_pos, parse_important)
|
|
316
|
-
# Parse property name (scan until ':')
|
|
457
|
+
# Parse property name (scan until ':').
|
|
458
|
+
# Also stops at '{' - a property name can never legitimately contain
|
|
459
|
+
# one, so its presence means this is actually an unsupported/invalid
|
|
460
|
+
# nested selector (e.g. a bare type selector without '&') that wasn't
|
|
461
|
+
# recognized as nesting. Treating it as malformed here (instead of
|
|
462
|
+
# scanning through the '{' looking for a colon) keeps its matching '}'
|
|
463
|
+
# from being silently swallowed later, which was corrupting output
|
|
464
|
+
# with unbalanced braces.
|
|
317
465
|
prop_start = pos
|
|
318
466
|
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
319
|
-
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
|
|
467
|
+
@_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE &&
|
|
468
|
+
@_css.getbyte(pos) != BYTE_LBRACE
|
|
320
469
|
pos += 1
|
|
321
470
|
end
|
|
322
471
|
|
|
@@ -352,9 +501,20 @@ module Cataract
|
|
|
352
501
|
pos += 1
|
|
353
502
|
end
|
|
354
503
|
|
|
355
|
-
# Parse value (scan until ';' or '}')
|
|
504
|
+
# Parse value (scan until ';' outside parens, or '}'). Paren depth
|
|
505
|
+
# tracking keeps a ';' inside url(...)/rgba(...) from ending the value
|
|
506
|
+
# early - e.g. "url(data:image/svg+xml;base64,...)".
|
|
356
507
|
val_start = pos
|
|
357
|
-
|
|
508
|
+
paren_depth = 0
|
|
509
|
+
while pos < end_pos && @_css.getbyte(pos) != BYTE_RBRACE
|
|
510
|
+
byte = @_css.getbyte(pos)
|
|
511
|
+
if byte == BYTE_LPAREN
|
|
512
|
+
paren_depth += 1
|
|
513
|
+
elsif byte == BYTE_RPAREN
|
|
514
|
+
paren_depth -= 1
|
|
515
|
+
elsif byte == BYTE_SEMICOLON && paren_depth == 0
|
|
516
|
+
break
|
|
517
|
+
end
|
|
358
518
|
pos += 1
|
|
359
519
|
end
|
|
360
520
|
val_end = pos
|
|
@@ -367,12 +527,7 @@ module Cataract
|
|
|
367
527
|
value = byteslice_encoded(val_start, val_end - val_start)
|
|
368
528
|
|
|
369
529
|
# Parse !important flag if requested
|
|
370
|
-
important =
|
|
371
|
-
if parse_important && value.end_with?('!important')
|
|
372
|
-
important = true
|
|
373
|
-
# Remove '!important' and trailing whitespace
|
|
374
|
-
value = value[0, value.length - 10].rstrip
|
|
375
|
-
end
|
|
530
|
+
important = parse_important && extract_important!(value)
|
|
376
531
|
|
|
377
532
|
# Skip semicolon if present
|
|
378
533
|
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
@@ -410,20 +565,35 @@ module Cataract
|
|
|
410
565
|
pos += 1
|
|
411
566
|
end
|
|
412
567
|
|
|
568
|
+
# Reached EOF without finding matching closing brace
|
|
569
|
+
if @_check_unclosed_blocks && depth > 0
|
|
570
|
+
raise ParseError.new('Unclosed block: missing closing brace',
|
|
571
|
+
css: @_css, pos: start_pos - 1, type: :unclosed_block)
|
|
572
|
+
end
|
|
573
|
+
|
|
413
574
|
pos
|
|
414
575
|
end
|
|
415
576
|
|
|
416
577
|
# Parse selector (read until '{')
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
578
|
+
# Advance @_pos to the next unescaped '{', or through to EOF if none is
|
|
579
|
+
# found. Doesn't consume the brace itself. Shared by parse_selector and
|
|
580
|
+
# scan_at_rule_selector, which both need this same scan but differ in
|
|
581
|
+
# what they do with the boundary afterward (validation, trimming, what
|
|
582
|
+
# to return on EOF).
|
|
583
|
+
#
|
|
584
|
+
# @return [Boolean] true if '{' was found, false if EOF was hit first
|
|
585
|
+
def skip_to_opening_brace # rubocop:disable Naming/PredicateMethod
|
|
421
586
|
until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
422
587
|
@_pos += 1
|
|
423
588
|
end
|
|
589
|
+
!eof?
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def parse_selector
|
|
593
|
+
start_pos = @_pos
|
|
424
594
|
|
|
425
595
|
# If we hit EOF without finding '{', return nil
|
|
426
|
-
return nil
|
|
596
|
+
return nil unless skip_to_opening_brace
|
|
427
597
|
|
|
428
598
|
# Extract selector text
|
|
429
599
|
selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
|
|
@@ -433,6 +603,29 @@ module Cataract
|
|
|
433
603
|
|
|
434
604
|
# Trim whitespace from selector (in-place to avoid allocation)
|
|
435
605
|
selector_text.strip!
|
|
606
|
+
|
|
607
|
+
# Validate selector (strict mode) - only if enabled to avoid overhead
|
|
608
|
+
if @_check_invalid_selectors
|
|
609
|
+
# Check for empty selector
|
|
610
|
+
if selector_text.empty?
|
|
611
|
+
raise ParseError.new('Invalid selector: empty selector',
|
|
612
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Check if selector starts with a combinator (>, +, ~)
|
|
616
|
+
first_char = selector_text.getbyte(0)
|
|
617
|
+
if first_char == BYTE_GT || first_char == BYTE_PLUS || first_char == BYTE_TILDE
|
|
618
|
+
raise ParseError.new("Invalid selector: selector cannot start with combinator '#{selector_text[0]}'",
|
|
619
|
+
css: @_css, pos: start_pos, type: :invalid_selector)
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Check selector syntax (whitelist validation for invalid characters/sequences)
|
|
624
|
+
if @_check_invalid_selector_syntax && !valid_selector_syntax?(selector_text)
|
|
625
|
+
raise ParseError.new('Invalid selector syntax: selector contains invalid characters',
|
|
626
|
+
css: @_css, pos: start_pos, type: :invalid_selector_syntax)
|
|
627
|
+
end
|
|
628
|
+
|
|
436
629
|
selector_text
|
|
437
630
|
end
|
|
438
631
|
|
|
@@ -514,7 +707,7 @@ module Cataract
|
|
|
514
707
|
|
|
515
708
|
# This should never happen - parent_media_query_id should always be valid
|
|
516
709
|
if parent_mq.nil?
|
|
517
|
-
raise
|
|
710
|
+
raise ParseError, "Invalid parent_media_query_id: #{parent_media_query_id} (not found in @media_queries)"
|
|
518
711
|
end
|
|
519
712
|
|
|
520
713
|
# Combine parent media query with child
|
|
@@ -664,17 +857,35 @@ module Cataract
|
|
|
664
857
|
break
|
|
665
858
|
end
|
|
666
859
|
|
|
667
|
-
# Parse property name (read until ':')
|
|
860
|
+
# Parse property name (read until ':'). Also stops at '{' - a property
|
|
861
|
+
# name can never legitimately contain one, so its presence means this
|
|
862
|
+
# is actually an unsupported/invalid nested selector (e.g. a bare type
|
|
863
|
+
# selector without '&') that wasn't recognized as nesting. Treating it
|
|
864
|
+
# as malformed here (instead of scanning through the '{' looking for a
|
|
865
|
+
# colon) keeps its matching '}' from being silently swallowed later,
|
|
866
|
+
# which was corrupting output with unbalanced braces.
|
|
668
867
|
property_start = @_pos
|
|
669
868
|
until eof?
|
|
670
869
|
byte = peek_byte
|
|
671
|
-
break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE
|
|
870
|
+
break if byte == BYTE_COLON || byte == BYTE_SEMICOLON || byte == BYTE_RBRACE || byte == BYTE_LBRACE
|
|
672
871
|
|
|
673
872
|
@_pos += 1
|
|
674
873
|
end
|
|
675
874
|
|
|
676
875
|
# Skip if no colon found (malformed)
|
|
677
876
|
if eof? || peek_byte != BYTE_COLON
|
|
877
|
+
# Check for malformed declaration (strict mode)
|
|
878
|
+
if @_check_malformed_declarations
|
|
879
|
+
property_text = byteslice_encoded(property_start, @_pos - property_start).strip
|
|
880
|
+
if property_text.empty?
|
|
881
|
+
raise ParseError.new('Malformed declaration: missing property name',
|
|
882
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
883
|
+
else
|
|
884
|
+
raise ParseError.new("Malformed declaration: missing colon after property '#{property_text}'",
|
|
885
|
+
css: @_css, pos: property_start, type: :malformed_declaration)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
678
889
|
# Try to recover by finding next ; or }
|
|
679
890
|
skip_to_semicolon_or_brace
|
|
680
891
|
next
|
|
@@ -694,10 +905,13 @@ module Cataract
|
|
|
694
905
|
|
|
695
906
|
skip_ws_and_comments
|
|
696
907
|
|
|
697
|
-
# Parse value (read until ';' or '}', but respect
|
|
908
|
+
# Parse value (read until ';' outside parens, or '}', but respect
|
|
909
|
+
# quoted strings). Paren depth tracking keeps a ';' inside
|
|
910
|
+
# url(...)/rgba(...) from ending the value early - e.g.
|
|
911
|
+
# "url(data:image/svg+xml;base64,...)".
|
|
698
912
|
value_start = @_pos
|
|
699
|
-
important = false
|
|
700
913
|
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
914
|
+
paren_depth = 0
|
|
701
915
|
|
|
702
916
|
until eof?
|
|
703
917
|
byte = peek_byte
|
|
@@ -711,11 +925,17 @@ module Cataract
|
|
|
711
925
|
@_pos += 1
|
|
712
926
|
end
|
|
713
927
|
else
|
|
714
|
-
# Not in quote - check for terminators or quote start
|
|
715
|
-
break if byte ==
|
|
928
|
+
# Not in quote - check for terminators or quote/paren start
|
|
929
|
+
break if byte == BYTE_RBRACE || (byte == BYTE_SEMICOLON && paren_depth == 0)
|
|
716
930
|
|
|
717
|
-
|
|
931
|
+
# case/when compiles to opt_send(===) here, not opt_eq - benchmarked
|
|
932
|
+
# ~1.7x slower without YJIT, still slower with it.
|
|
933
|
+
if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE # rubocop:disable Style/CaseLikeIf
|
|
718
934
|
in_quote = byte
|
|
935
|
+
elsif byte == BYTE_LPAREN
|
|
936
|
+
paren_depth += 1
|
|
937
|
+
elsif byte == BYTE_RPAREN
|
|
938
|
+
paren_depth -= 1
|
|
719
939
|
end
|
|
720
940
|
end
|
|
721
941
|
|
|
@@ -726,35 +946,12 @@ module Cataract
|
|
|
726
946
|
value.strip!
|
|
727
947
|
|
|
728
948
|
# Check for !important (byte-by-byte, no regexp)
|
|
729
|
-
|
|
730
|
-
# Scan backwards to find !important
|
|
731
|
-
i = value.bytesize - 1
|
|
732
|
-
# Skip trailing whitespace
|
|
733
|
-
while i >= 0
|
|
734
|
-
b = value.getbyte(i)
|
|
735
|
-
break unless b == BYTE_SPACE || b == BYTE_TAB
|
|
736
|
-
|
|
737
|
-
i -= 1
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
# Check for 'important' (9 chars)
|
|
741
|
-
if i >= 8 && value[(i - 8), 9] == 'important'
|
|
742
|
-
i -= 9
|
|
743
|
-
# Skip whitespace before 'important'
|
|
744
|
-
while i >= 0
|
|
745
|
-
b = value.getbyte(i)
|
|
746
|
-
break unless b == BYTE_SPACE || b == BYTE_TAB
|
|
949
|
+
important = extract_important!(value)
|
|
747
950
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
important = true
|
|
753
|
-
# Remove everything from '!' onwards (use byteslice and strip in-place)
|
|
754
|
-
value = value.byteslice(0, i)
|
|
755
|
-
value.strip!
|
|
756
|
-
end
|
|
757
|
-
end
|
|
951
|
+
# Check for empty value (strict mode) - only if enabled to avoid overhead
|
|
952
|
+
if @_check_empty_values && value.empty?
|
|
953
|
+
raise ParseError.new("Empty value for property '#{property}'",
|
|
954
|
+
css: @_css, pos: property_start, type: :empty_value)
|
|
758
955
|
end
|
|
759
956
|
|
|
760
957
|
# Skip semicolon if present
|
|
@@ -770,6 +967,82 @@ module Cataract
|
|
|
770
967
|
declarations
|
|
771
968
|
end
|
|
772
969
|
|
|
970
|
+
# Scan from at_rule_start (pointing at the '@') to the at-rule's opening
|
|
971
|
+
# brace, returning the trimmed selector text (e.g. "@keyframes fade").
|
|
972
|
+
# Leaves @_pos just past the opening brace. Shared by at-rule kinds whose
|
|
973
|
+
# selector is just "everything up to '{'" with no separate condition/query
|
|
974
|
+
# text to extract along the way (@keyframes, @font-face, and the unknown/
|
|
975
|
+
# default at-rule fallback).
|
|
976
|
+
#
|
|
977
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
978
|
+
# @return [String, nil] The trimmed selector, or nil if '{' was never found
|
|
979
|
+
def scan_at_rule_selector(at_rule_start)
|
|
980
|
+
return nil unless skip_to_opening_brace
|
|
981
|
+
|
|
982
|
+
selector_end = @_pos
|
|
983
|
+
while selector_end > at_rule_start && whitespace?(@_css.getbyte(selector_end - 1))
|
|
984
|
+
selector_end -= 1
|
|
985
|
+
end
|
|
986
|
+
selector = byteslice_encoded(at_rule_start, selector_end - at_rule_start)
|
|
987
|
+
|
|
988
|
+
@_pos += 1 # skip '{'
|
|
989
|
+
selector
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# Merge selector_lists from a nested Parser's result into this parser,
|
|
993
|
+
# offsetting list ids and rule ids to avoid collisions. Shared by the
|
|
994
|
+
# conditional-group (@supports/@layer/@container/@scope) and @media
|
|
995
|
+
# at-rule handlers, which both recurse into a fresh Parser instance for
|
|
996
|
+
# their block content.
|
|
997
|
+
#
|
|
998
|
+
# @param nested_result [Hash] Result hash from a nested Parser#parse call
|
|
999
|
+
# @return [Integer] The list_id_offset used - pass through to
|
|
1000
|
+
# merge_nested_rules so each rule's selector_list_id stays consistent
|
|
1001
|
+
def merge_nested_selector_lists(nested_result)
|
|
1002
|
+
list_id_offset = @_next_selector_list_id
|
|
1003
|
+
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
1004
|
+
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
1005
|
+
new_list_id = list_id + list_id_offset
|
|
1006
|
+
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
1007
|
+
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
1008
|
+
end
|
|
1009
|
+
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1010
|
+
end
|
|
1011
|
+
list_id_offset
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Merge rules from a nested Parser's result into this parser's @rules,
|
|
1015
|
+
# renumbering ids and offsetting selector_list_id, then propagate whether
|
|
1016
|
+
# nesting was found inside the block up to this parser - the nested
|
|
1017
|
+
# parser tracked its own independent @_has_nesting, so without this a
|
|
1018
|
+
# rule nested inside a top-level at-rule block would carry a correct
|
|
1019
|
+
# parent_rule_id but the stylesheet would still report has_nesting:
|
|
1020
|
+
# false, causing serialization to use the flat, non-nesting-aware path
|
|
1021
|
+
# and print the rule's already-resolved selector as an unrelated
|
|
1022
|
+
# top-level rule instead of reconstructing the nested syntax.
|
|
1023
|
+
#
|
|
1024
|
+
# Yields each rule (after id/selector_list_id are updated, before it's
|
|
1025
|
+
# appended to @rules) for callers needing extra per-rule handling - e.g.
|
|
1026
|
+
# @media combining media_query_id with its outer media context.
|
|
1027
|
+
#
|
|
1028
|
+
# @param nested_result [Hash] Result hash from a nested Parser#parse call
|
|
1029
|
+
# @param list_id_offset [Integer] Offset from merge_nested_selector_lists
|
|
1030
|
+
def merge_nested_rules(nested_result, list_id_offset)
|
|
1031
|
+
nested_result[:rules].each do |rule|
|
|
1032
|
+
rule.id = @_rule_id_counter
|
|
1033
|
+
if rule.is_a?(Rule) && rule.selector_list_id
|
|
1034
|
+
rule.selector_list_id += list_id_offset
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
yield rule if block_given?
|
|
1038
|
+
|
|
1039
|
+
@_rule_id_counter += 1
|
|
1040
|
+
@rules << rule
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
@_has_nesting ||= nested_result[:_has_nesting] # rubocop:disable Naming/MemoizedInstanceVariableName -- not memoization, propagating a flag from the nested parse
|
|
1044
|
+
end
|
|
1045
|
+
|
|
773
1046
|
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
774
1047
|
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
775
1048
|
def parse_at_rule
|
|
@@ -788,401 +1061,363 @@ module Cataract
|
|
|
788
1061
|
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
789
1062
|
|
|
790
1063
|
# Handle @charset specially - it's just @charset "value";
|
|
791
|
-
if at_rule_name == 'charset'
|
|
792
|
-
skip_ws_and_comments
|
|
793
|
-
# Read until semicolon
|
|
794
|
-
value_start = @_pos
|
|
795
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
796
|
-
@_pos += 1
|
|
797
|
-
end
|
|
798
|
-
|
|
799
|
-
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
800
|
-
charset_value.strip!
|
|
801
|
-
# Remove quotes
|
|
802
|
-
@charset = charset_value.delete('"\'')
|
|
803
|
-
|
|
804
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
805
|
-
return
|
|
806
|
-
end
|
|
1064
|
+
return parse_charset_at_rule if at_rule_name == 'charset'
|
|
807
1065
|
|
|
808
1066
|
# Handle @import - must come before rules (except @charset)
|
|
809
|
-
if at_rule_name == 'import'
|
|
810
|
-
# If we've already seen a rule, this @import is invalid
|
|
811
|
-
if @rules.size > 0
|
|
812
|
-
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
813
|
-
# Skip to semicolon
|
|
814
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
815
|
-
@_pos += 1
|
|
816
|
-
end
|
|
817
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
818
|
-
return
|
|
819
|
-
end
|
|
820
|
-
|
|
821
|
-
parse_import_statement
|
|
822
|
-
return
|
|
823
|
-
end
|
|
1067
|
+
return parse_import_at_rule if at_rule_name == 'import'
|
|
824
1068
|
|
|
825
1069
|
# Handle conditional group at-rules: @supports, @layer, @container, @scope
|
|
826
1070
|
# These behave like @media but don't affect media context
|
|
827
|
-
if AT_RULE_TYPES.include?(at_rule_name)
|
|
828
|
-
skip_ws_and_comments
|
|
1071
|
+
return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
|
|
829
1072
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
@_pos += 1
|
|
833
|
-
end
|
|
1073
|
+
# Handle @media specially - parse content and track in media_index
|
|
1074
|
+
return parse_media_at_rule if at_rule_name == 'media'
|
|
834
1075
|
|
|
835
|
-
|
|
1076
|
+
# Check for @keyframes (contains <rule-list>)
|
|
1077
|
+
is_keyframes = at_rule_name == 'keyframes' ||
|
|
1078
|
+
at_rule_name == '-webkit-keyframes' ||
|
|
1079
|
+
at_rule_name == '-moz-keyframes'
|
|
1080
|
+
return parse_keyframes_at_rule(at_rule_start) if is_keyframes
|
|
836
1081
|
|
|
837
|
-
|
|
1082
|
+
# Check for @font-face (contains <declaration-list>)
|
|
1083
|
+
return parse_font_face_at_rule(at_rule_start) if at_rule_name == 'font-face'
|
|
838
1084
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1085
|
+
# Unknown at-rule (@property, @page, @counter-style, etc.)
|
|
1086
|
+
# Treat as a regular selector-based rule with declarations
|
|
1087
|
+
parse_unknown_at_rule(at_rule_start)
|
|
1088
|
+
end
|
|
842
1089
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1090
|
+
# @charset "value"; - stores the value and consumes through the semicolon.
|
|
1091
|
+
def parse_charset_at_rule
|
|
1092
|
+
skip_ws_and_comments
|
|
1093
|
+
# Read until semicolon
|
|
1094
|
+
value_start = @_pos
|
|
1095
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1096
|
+
@_pos += 1
|
|
1097
|
+
end
|
|
847
1098
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
parent_media_sym: @_parent_media_sym,
|
|
853
|
-
depth: @_depth + 1
|
|
854
|
-
)
|
|
855
|
-
|
|
856
|
-
nested_result = nested_parser.parse
|
|
857
|
-
|
|
858
|
-
# Merge nested selector_lists with offsetted IDs
|
|
859
|
-
list_id_offset = @_next_selector_list_id
|
|
860
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
861
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
862
|
-
new_list_id = list_id + list_id_offset
|
|
863
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
864
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
865
|
-
end
|
|
866
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
867
|
-
end
|
|
1099
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
1100
|
+
charset_value.strip!
|
|
1101
|
+
# Remove quotes
|
|
1102
|
+
@charset = charset_value.delete('"\'')
|
|
868
1103
|
|
|
869
|
-
|
|
870
|
-
|
|
1104
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1105
|
+
end
|
|
871
1106
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1107
|
+
# @import must appear before all rules (except @charset) per CSS spec -
|
|
1108
|
+
# anything else is invalid and gets skipped with a warning instead of
|
|
1109
|
+
# being parsed as an import.
|
|
1110
|
+
def parse_import_at_rule
|
|
1111
|
+
# If we've already seen a rule, this @import is invalid
|
|
1112
|
+
if @rules.size > 0
|
|
1113
|
+
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
1114
|
+
# Skip to semicolon
|
|
1115
|
+
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
1116
|
+
@_pos += 1
|
|
881
1117
|
end
|
|
882
|
-
|
|
883
|
-
# Move position past the closing brace
|
|
884
|
-
@_pos = block_end
|
|
885
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
886
|
-
|
|
1118
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
887
1119
|
return
|
|
888
1120
|
end
|
|
889
1121
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
skip_ws_and_comments
|
|
1122
|
+
parse_import_statement
|
|
1123
|
+
end
|
|
893
1124
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1125
|
+
# @supports, @layer, @container, @scope - behave like @media but don't
|
|
1126
|
+
# affect media context, so unlike parse_media_at_rule there's no media
|
|
1127
|
+
# query/media_query_id merging to do on top of the shared nested-parser
|
|
1128
|
+
# result merge.
|
|
1129
|
+
#
|
|
1130
|
+
# @param at_rule_name [String] The at-rule name (e.g. "supports"), used
|
|
1131
|
+
# to decide whether a condition is required and for error messages
|
|
1132
|
+
def parse_conditional_group_at_rule(at_rule_name)
|
|
1133
|
+
skip_ws_and_comments
|
|
899
1134
|
|
|
900
|
-
|
|
1135
|
+
# Remember start of condition for error reporting
|
|
1136
|
+
condition_start = @_pos
|
|
901
1137
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1138
|
+
# Skip to opening brace
|
|
1139
|
+
condition_end = @_pos
|
|
1140
|
+
while !eof? && peek_byte != BYTE_LBRACE
|
|
1141
|
+
condition_end = @_pos
|
|
1142
|
+
@_pos += 1
|
|
1143
|
+
end
|
|
907
1144
|
|
|
908
|
-
|
|
909
|
-
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
910
|
-
child_media_string.strip!
|
|
911
|
-
child_media_sym = child_media_string.to_sym
|
|
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
|
|
1145
|
+
return if eof? || peek_byte != BYTE_LBRACE
|
|
926
1146
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1147
|
+
# Validate condition (strict mode) - @supports, @container, @scope require conditions
|
|
1148
|
+
if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
|
|
1149
|
+
condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
|
|
1150
|
+
if condition_str.empty?
|
|
1151
|
+
raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
|
|
1152
|
+
css: @_css, pos: condition_start, type: :malformed_at_rule)
|
|
931
1153
|
end
|
|
1154
|
+
end
|
|
932
1155
|
|
|
933
|
-
|
|
934
|
-
current_media_query_id = media_query_ids.first
|
|
935
|
-
|
|
936
|
-
# Combine with parent media context
|
|
937
|
-
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1156
|
+
@_pos += 1 # skip '{'
|
|
938
1157
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
# So this is just an alias to current_media_query_id.
|
|
943
|
-
combined_media_query_id = current_media_query_id
|
|
1158
|
+
# Find matching closing brace
|
|
1159
|
+
block_start = @_pos
|
|
1160
|
+
block_end = find_matching_brace(@_pos)
|
|
944
1161
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
950
|
-
end
|
|
951
|
-
end
|
|
1162
|
+
# Check depth before recursing
|
|
1163
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1164
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1165
|
+
end
|
|
952
1166
|
|
|
953
|
-
|
|
1167
|
+
# Recursively parse block content (preserve parent media context)
|
|
1168
|
+
nested_parser = Parser.new(
|
|
1169
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1170
|
+
parser_options: @_parser_options,
|
|
1171
|
+
parent_media_sym: @_parent_media_sym,
|
|
1172
|
+
depth: @_depth + 1
|
|
1173
|
+
)
|
|
954
1174
|
|
|
955
|
-
|
|
956
|
-
block_start = @_pos
|
|
957
|
-
block_end = find_matching_brace(@_pos)
|
|
1175
|
+
nested_result = nested_parser.parse
|
|
958
1176
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
|
967
|
-
nested_parser = Parser.new(
|
|
968
|
-
byteslice_encoded(block_start, block_end - block_start),
|
|
969
|
-
parser_options: @_parser_options,
|
|
970
|
-
parent_media_sym: combined_media_sym,
|
|
971
|
-
depth: @_depth + 1
|
|
972
|
-
)
|
|
973
|
-
|
|
974
|
-
nested_result = nested_parser.parse
|
|
975
|
-
|
|
976
|
-
# Merge nested selector_lists with offsetted IDs
|
|
977
|
-
list_id_offset = @_next_selector_list_id
|
|
978
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
979
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
980
|
-
new_list_id = list_id + list_id_offset
|
|
981
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
982
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
983
|
-
end
|
|
984
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
985
|
-
end
|
|
1177
|
+
# NOTE: We no longer build media_index during parse
|
|
1178
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1179
|
+
list_id_offset = merge_nested_selector_lists(nested_result)
|
|
1180
|
+
merge_nested_rules(nested_result, list_id_offset)
|
|
986
1181
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
1182
|
+
# Move position past the closing brace
|
|
1183
|
+
@_pos = block_end
|
|
1184
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1185
|
+
end
|
|
997
1186
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
end
|
|
1006
|
-
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1007
|
-
end
|
|
1187
|
+
# @media - parses the media query, recurses into a nested Parser for the
|
|
1188
|
+
# block content (combining its media context with any parent), and
|
|
1189
|
+
# merges the result. Unlike parse_conditional_group_at_rule, this also
|
|
1190
|
+
# merges MediaQuery objects/lists and combines each nested rule's
|
|
1191
|
+
# media_query_id with this block's own media context.
|
|
1192
|
+
def parse_media_at_rule
|
|
1193
|
+
skip_ws_and_comments
|
|
1008
1194
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1195
|
+
# Find media query (up to opening brace)
|
|
1196
|
+
mq_start = @_pos
|
|
1197
|
+
return unless skip_to_opening_brace
|
|
1012
1198
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
rule.selector_list_id += list_id_offset
|
|
1019
|
-
end
|
|
1199
|
+
mq_end = @_pos
|
|
1200
|
+
# Trim trailing whitespace
|
|
1201
|
+
while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
|
|
1202
|
+
mq_end -= 1
|
|
1203
|
+
end
|
|
1020
1204
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
|
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
|
|
1047
|
-
end
|
|
1205
|
+
child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
|
|
1206
|
+
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
1207
|
+
child_media_string.strip!
|
|
1048
1208
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1209
|
+
# Validate @media has a query (strict mode)
|
|
1210
|
+
if @_check_malformed_at_rules && child_media_string.empty?
|
|
1211
|
+
raise ParseError.new('Malformed @media: missing media query or condition',
|
|
1212
|
+
css: @_css, pos: mq_start, type: :malformed_at_rule)
|
|
1213
|
+
end
|
|
1051
1214
|
|
|
1052
|
-
|
|
1053
|
-
@rules << rule
|
|
1054
|
-
end
|
|
1215
|
+
child_media_sym = child_media_string.to_sym
|
|
1055
1216
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1217
|
+
# Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
|
|
1218
|
+
# Per W3C spec, comma acts as logical OR - each query is independent
|
|
1219
|
+
media_query_strings = child_media_string.split(',').map(&:strip)
|
|
1059
1220
|
|
|
1060
|
-
|
|
1221
|
+
# Create MediaQuery objects for each query in the list
|
|
1222
|
+
media_query_ids = []
|
|
1223
|
+
media_query_strings.each do |query_string|
|
|
1224
|
+
media_type, media_conditions = parse_media_query_parts(query_string)
|
|
1225
|
+
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1226
|
+
@media_queries << media_query
|
|
1227
|
+
media_query_ids << @_media_query_id_counter
|
|
1228
|
+
@_media_query_id_counter += 1
|
|
1061
1229
|
end
|
|
1062
1230
|
|
|
1063
|
-
#
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1231
|
+
# If multiple queries, track them as a list for serialization
|
|
1232
|
+
if media_query_ids.size > 1
|
|
1233
|
+
@_media_query_lists[@_next_media_query_list_id] = media_query_ids
|
|
1234
|
+
@_next_media_query_list_id += 1
|
|
1235
|
+
end
|
|
1067
1236
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
selector_start = at_rule_start # Points to '@'
|
|
1237
|
+
# Use first query ID as the primary one for rules in this block
|
|
1238
|
+
current_media_query_id = media_query_ids.first
|
|
1071
1239
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
@_pos += 1
|
|
1075
|
-
end
|
|
1240
|
+
# Combine with parent media context
|
|
1241
|
+
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1076
1242
|
|
|
1077
|
-
|
|
1243
|
+
# NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
|
|
1244
|
+
# create separate parsers without passing parent_media_query_id (see nested_parser creation below).
|
|
1245
|
+
# MediaQuery combining for nested @media happens in parse_mixed_block instead.
|
|
1246
|
+
# So this is just an alias to current_media_query_id.
|
|
1247
|
+
combined_media_query_id = current_media_query_id
|
|
1078
1248
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1249
|
+
# Check media query limit
|
|
1250
|
+
unless @media_index.key?(combined_media_sym)
|
|
1251
|
+
@_media_query_count += 1
|
|
1252
|
+
if @_media_query_count > MAX_MEDIA_QUERIES
|
|
1253
|
+
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
1083
1254
|
end
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
@_pos += 1 # skip '{'
|
|
1255
|
+
end
|
|
1087
1256
|
|
|
1088
|
-
|
|
1089
|
-
block_start = @_pos
|
|
1090
|
-
block_end = find_matching_brace(@_pos)
|
|
1257
|
+
@_pos += 1 # skip '{'
|
|
1091
1258
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
end
|
|
1259
|
+
# Find matching closing brace
|
|
1260
|
+
block_start = @_pos
|
|
1261
|
+
block_end = find_matching_brace(@_pos)
|
|
1096
1262
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
parser_options: @_parser_options,
|
|
1102
|
-
depth: @_depth + 1
|
|
1103
|
-
)
|
|
1104
|
-
nested_result = nested_parser.parse
|
|
1105
|
-
content = nested_result[:rules]
|
|
1106
|
-
|
|
1107
|
-
# Move position past the closing brace
|
|
1108
|
-
@_pos = block_end
|
|
1109
|
-
# The closing brace should be at block_end
|
|
1110
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1111
|
-
|
|
1112
|
-
# Get rule ID and increment
|
|
1113
|
-
rule_id = @_rule_id_counter
|
|
1114
|
-
@_rule_id_counter += 1
|
|
1263
|
+
# Check depth before recursing
|
|
1264
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1265
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1266
|
+
end
|
|
1115
1267
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1268
|
+
# Parse the content with the combined media context
|
|
1269
|
+
# Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
|
|
1270
|
+
# The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
|
|
1271
|
+
nested_parser = Parser.new(
|
|
1272
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1273
|
+
parser_options: @_parser_options,
|
|
1274
|
+
parent_media_sym: combined_media_sym,
|
|
1275
|
+
depth: @_depth + 1
|
|
1276
|
+
)
|
|
1119
1277
|
|
|
1120
|
-
|
|
1121
|
-
end
|
|
1278
|
+
nested_result = nested_parser.parse
|
|
1122
1279
|
|
|
1123
|
-
|
|
1124
|
-
if at_rule_name == 'font-face'
|
|
1125
|
-
# Build selector string: "@font-face"
|
|
1126
|
-
selector_start = at_rule_start # Points to '@'
|
|
1280
|
+
list_id_offset = merge_nested_selector_lists(nested_result)
|
|
1127
1281
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1282
|
+
# Merge nested MediaQuery objects with offsetted IDs
|
|
1283
|
+
mq_id_offset = @_media_query_id_counter
|
|
1284
|
+
if nested_result[:media_queries] && !nested_result[:media_queries].empty?
|
|
1285
|
+
nested_result[:media_queries].each do |mq|
|
|
1286
|
+
# Create new MediaQuery with offsetted ID
|
|
1287
|
+
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
1288
|
+
@media_queries << new_mq
|
|
1131
1289
|
end
|
|
1290
|
+
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1291
|
+
end
|
|
1132
1292
|
|
|
1133
|
-
|
|
1293
|
+
# Merge nested media_query_lists with offsetted IDs
|
|
1294
|
+
if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
|
|
1295
|
+
nested_result[:_media_query_lists].each do |list_id, mq_ids|
|
|
1296
|
+
# Offset the list_id and media_query_ids
|
|
1297
|
+
new_list_id = list_id + @_next_media_query_list_id
|
|
1298
|
+
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1299
|
+
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1300
|
+
end
|
|
1301
|
+
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1302
|
+
end
|
|
1134
1303
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1304
|
+
# Merge nested media_index into ours (for nested @media)
|
|
1305
|
+
# Note: We no longer build media_index during parse
|
|
1306
|
+
# It will be built from MediaQuery objects after import resolution
|
|
1307
|
+
|
|
1308
|
+
merge_nested_rules(nested_result, list_id_offset) do |rule|
|
|
1309
|
+
# Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
|
|
1310
|
+
if rule.media_query_id
|
|
1311
|
+
# Nested parser assigned a media_query_id - need to combine with our context
|
|
1312
|
+
nested_mq_id = rule.media_query_id + mq_id_offset
|
|
1313
|
+
nested_mq = @media_queries[nested_mq_id]
|
|
1314
|
+
|
|
1315
|
+
# Combine nested media query with our media context
|
|
1316
|
+
if nested_mq && combined_media_query_id
|
|
1317
|
+
outer_mq = @media_queries[combined_media_query_id]
|
|
1318
|
+
if outer_mq
|
|
1319
|
+
# Combine media queries directly without string building
|
|
1320
|
+
combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
|
|
1321
|
+
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
1322
|
+
@media_queries << combined_mq
|
|
1323
|
+
rule.media_query_id = @_media_query_id_counter
|
|
1324
|
+
@_media_query_id_counter += 1
|
|
1325
|
+
else
|
|
1326
|
+
rule.media_query_id = nested_mq_id
|
|
1327
|
+
end
|
|
1328
|
+
else
|
|
1329
|
+
rule.media_query_id = nested_mq_id
|
|
1330
|
+
end
|
|
1331
|
+
elsif rule.respond_to?(:media_query_id=)
|
|
1332
|
+
# Assign the combined media_query_id if no media_query_id set
|
|
1333
|
+
# (applies to both Rule and AtRule)
|
|
1334
|
+
rule.media_query_id = combined_media_query_id
|
|
1139
1335
|
end
|
|
1140
|
-
|
|
1336
|
+
end
|
|
1141
1337
|
|
|
1142
|
-
|
|
1338
|
+
# Move position past the closing brace
|
|
1339
|
+
@_pos = block_end
|
|
1340
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1341
|
+
end
|
|
1143
1342
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1343
|
+
# @keyframes (contains a <rule-list> of percentage/from/to blocks)
|
|
1344
|
+
#
|
|
1345
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1346
|
+
def parse_keyframes_at_rule(at_rule_start)
|
|
1347
|
+
# Build full selector string: "@keyframes fade"
|
|
1348
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1349
|
+
return if selector.nil?
|
|
1350
|
+
|
|
1351
|
+
# Find matching closing brace
|
|
1352
|
+
block_start = @_pos
|
|
1353
|
+
block_end = find_matching_brace(@_pos)
|
|
1354
|
+
|
|
1355
|
+
# Check depth before recursing
|
|
1356
|
+
if @_depth + 1 > MAX_PARSE_DEPTH
|
|
1357
|
+
raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
|
|
1358
|
+
end
|
|
1147
1359
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1360
|
+
# Parse keyframe blocks as rules (0%/from/to etc)
|
|
1361
|
+
# Create a nested parser context
|
|
1362
|
+
nested_parser = Parser.new(
|
|
1363
|
+
byteslice_encoded(block_start, block_end - block_start),
|
|
1364
|
+
parser_options: @_parser_options,
|
|
1365
|
+
depth: @_depth + 1
|
|
1366
|
+
)
|
|
1367
|
+
nested_result = nested_parser.parse
|
|
1368
|
+
content = nested_result[:rules]
|
|
1150
1369
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1370
|
+
# Move position past the closing brace
|
|
1371
|
+
@_pos = block_end
|
|
1372
|
+
# The closing brace should be at block_end
|
|
1373
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1155
1374
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1375
|
+
# Get rule ID and increment
|
|
1376
|
+
rule_id = @_rule_id_counter
|
|
1377
|
+
@_rule_id_counter += 1
|
|
1159
1378
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1379
|
+
# Create AtRule with nested rules
|
|
1380
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1381
|
+
@rules << at_rule
|
|
1382
|
+
end
|
|
1163
1383
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1384
|
+
# @font-face (contains a <declaration-list>)
|
|
1385
|
+
#
|
|
1386
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1387
|
+
def parse_font_face_at_rule(at_rule_start)
|
|
1388
|
+
# Build selector string: "@font-face"
|
|
1389
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1390
|
+
return if selector.nil?
|
|
1166
1391
|
|
|
1167
|
-
#
|
|
1168
|
-
|
|
1169
|
-
|
|
1392
|
+
# Find matching closing brace
|
|
1393
|
+
decl_start = @_pos
|
|
1394
|
+
decl_end = find_matching_brace(@_pos)
|
|
1170
1395
|
|
|
1171
|
-
#
|
|
1172
|
-
|
|
1173
|
-
@_pos += 1
|
|
1174
|
-
end
|
|
1396
|
+
# Parse declarations
|
|
1397
|
+
content = parse_declarations_block(decl_start, decl_end)
|
|
1175
1398
|
|
|
1176
|
-
|
|
1399
|
+
# Move position past the closing brace
|
|
1400
|
+
@_pos = decl_end
|
|
1401
|
+
# The closing brace should be at decl_end
|
|
1402
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1177
1403
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
selector_end -= 1
|
|
1182
|
-
end
|
|
1183
|
-
selector = byteslice_encoded(selector_start, selector_end - selector_start)
|
|
1404
|
+
# Get rule ID and increment
|
|
1405
|
+
rule_id = @_rule_id_counter
|
|
1406
|
+
@_rule_id_counter += 1
|
|
1184
1407
|
|
|
1185
|
-
|
|
1408
|
+
# Create AtRule with declarations
|
|
1409
|
+
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1410
|
+
@rules << at_rule
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# Unknown at-rule (@property, @page, @counter-style, etc.) - treated as a
|
|
1414
|
+
# regular selector-based rule with declarations, since this parser has no
|
|
1415
|
+
# special handling for it.
|
|
1416
|
+
#
|
|
1417
|
+
# @param at_rule_start [Integer] Position of the at-rule's leading '@'
|
|
1418
|
+
def parse_unknown_at_rule(at_rule_start)
|
|
1419
|
+
selector = scan_at_rule_selector(at_rule_start)
|
|
1420
|
+
return if selector.nil?
|
|
1186
1421
|
|
|
1187
1422
|
# Parse declarations
|
|
1188
1423
|
declarations = parse_declarations
|