cataract 0.2.5 → 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/CHANGELOG.md +19 -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 +247 -454
- data/ext/cataract/cataract.h +126 -15
- data/ext/cataract/css_parser.c +782 -788
- 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/import_resolver.rb +36 -16
- data/lib/cataract/pure/byte_constants.rb +0 -1
- data/lib/cataract/pure/flatten.rb +143 -145
- data/lib/cataract/pure/parser.rb +495 -422
- data/lib/cataract/pure/serializer.rb +109 -104
- data/lib/cataract/pure/specificity.rb +112 -156
- data/lib/cataract/pure.rb +0 -7
- data/lib/cataract/stylesheet.rb +460 -412
- data/lib/cataract/version.rb +1 -1
- metadata +2 -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
|
|
@@ -171,17 +167,9 @@ module Cataract
|
|
|
171
167
|
if has_nested_selectors?(decl_start, decl_end)
|
|
172
168
|
# NESTED PATH: Parse mixed declarations + nested rules
|
|
173
169
|
# Split comma-separated selectors and parse each one
|
|
174
|
-
selectors = selector
|
|
170
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
175
171
|
|
|
176
172
|
selectors.each do |individual_selector|
|
|
177
|
-
individual_selector.strip!
|
|
178
|
-
|
|
179
|
-
# Check for empty selector in comma-separated list
|
|
180
|
-
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
181
|
-
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
182
|
-
css: @_css, pos: decl_start, type: :invalid_selector_syntax)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
173
|
next if individual_selector.empty?
|
|
186
174
|
|
|
187
175
|
# Get rule ID for this selector
|
|
@@ -220,7 +208,7 @@ module Cataract
|
|
|
220
208
|
declarations = parse_declarations
|
|
221
209
|
|
|
222
210
|
# Split comma-separated selectors into individual rules
|
|
223
|
-
selectors = selector
|
|
211
|
+
selectors = split_and_validate_selectors(selector, decl_start)
|
|
224
212
|
|
|
225
213
|
# Determine if we should track this as a selector list
|
|
226
214
|
# Check boolean first to potentially avoid size() call via short-circuit evaluation
|
|
@@ -232,14 +220,6 @@ module Cataract
|
|
|
232
220
|
end
|
|
233
221
|
|
|
234
222
|
selectors.each do |individual_selector|
|
|
235
|
-
individual_selector.strip!
|
|
236
|
-
|
|
237
|
-
# Check for empty selector in comma-separated list
|
|
238
|
-
if @_check_invalid_selector_syntax && individual_selector.empty? && selectors.size > 1
|
|
239
|
-
raise ParseError.new('Invalid selector syntax: empty selector in comma-separated list',
|
|
240
|
-
css: @_css, pos: decl_start, type: :invalid_selector_syntax)
|
|
241
|
-
end
|
|
242
|
-
|
|
243
223
|
next if individual_selector.empty?
|
|
244
224
|
|
|
245
225
|
rule_id = @_rule_id_counter
|
|
@@ -287,6 +267,30 @@ module Cataract
|
|
|
287
267
|
|
|
288
268
|
private
|
|
289
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
|
+
|
|
290
294
|
# Check if we're at end of input
|
|
291
295
|
def eof?
|
|
292
296
|
@_pos >= @_len
|
|
@@ -397,20 +401,71 @@ module Cataract
|
|
|
397
401
|
true
|
|
398
402
|
end
|
|
399
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
|
+
|
|
400
447
|
# Parse a single CSS declaration (property: value)
|
|
401
448
|
#
|
|
402
449
|
# Performance-critical helper that parses one declaration.
|
|
403
|
-
# Shared by parse_mixed_block
|
|
450
|
+
# Shared by parse_mixed_block and parse_declarations_block.
|
|
404
451
|
#
|
|
405
452
|
# @param pos [Integer] Current position in CSS string
|
|
406
453
|
# @param end_pos [Integer] End position (boundary for parsing)
|
|
407
454
|
# @param parse_important [Boolean] Whether to parse !important flag (false for at-rules)
|
|
408
455
|
# @return [Array(Declaration|nil, Integer)] Tuple of [declaration, new_position]
|
|
409
456
|
def parse_single_declaration(pos, end_pos, parse_important)
|
|
410
|
-
# 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.
|
|
411
465
|
prop_start = pos
|
|
412
466
|
while pos < end_pos && @_css.getbyte(pos) != BYTE_COLON &&
|
|
413
|
-
@_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
|
|
414
469
|
pos += 1
|
|
415
470
|
end
|
|
416
471
|
|
|
@@ -446,9 +501,20 @@ module Cataract
|
|
|
446
501
|
pos += 1
|
|
447
502
|
end
|
|
448
503
|
|
|
449
|
-
# 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,...)".
|
|
450
507
|
val_start = pos
|
|
451
|
-
|
|
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
|
|
452
518
|
pos += 1
|
|
453
519
|
end
|
|
454
520
|
val_end = pos
|
|
@@ -461,12 +527,7 @@ module Cataract
|
|
|
461
527
|
value = byteslice_encoded(val_start, val_end - val_start)
|
|
462
528
|
|
|
463
529
|
# Parse !important flag if requested
|
|
464
|
-
important =
|
|
465
|
-
if parse_important && value.end_with?('!important')
|
|
466
|
-
important = true
|
|
467
|
-
# Remove '!important' and trailing whitespace
|
|
468
|
-
value = value[0, value.length - 10].rstrip
|
|
469
|
-
end
|
|
530
|
+
important = parse_important && extract_important!(value)
|
|
470
531
|
|
|
471
532
|
# Skip semicolon if present
|
|
472
533
|
pos += 1 if pos < end_pos && @_css.getbyte(pos) == BYTE_SEMICOLON
|
|
@@ -514,16 +575,25 @@ module Cataract
|
|
|
514
575
|
end
|
|
515
576
|
|
|
516
577
|
# Parse selector (read until '{')
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
521
586
|
until eof? || peek_byte == BYTE_LBRACE # Flip to save a 'opt_not' instruction: while !eof? && peek_byte != BYTE_LBRACE
|
|
522
587
|
@_pos += 1
|
|
523
588
|
end
|
|
589
|
+
!eof?
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def parse_selector
|
|
593
|
+
start_pos = @_pos
|
|
524
594
|
|
|
525
595
|
# If we hit EOF without finding '{', return nil
|
|
526
|
-
return nil
|
|
596
|
+
return nil unless skip_to_opening_brace
|
|
527
597
|
|
|
528
598
|
# Extract selector text
|
|
529
599
|
selector_text = byteslice_encoded(start_pos, @_pos - start_pos)
|
|
@@ -787,11 +857,17 @@ module Cataract
|
|
|
787
857
|
break
|
|
788
858
|
end
|
|
789
859
|
|
|
790
|
-
# 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.
|
|
791
867
|
property_start = @_pos
|
|
792
868
|
until eof?
|
|
793
869
|
byte = peek_byte
|
|
794
|
-
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
|
|
795
871
|
|
|
796
872
|
@_pos += 1
|
|
797
873
|
end
|
|
@@ -829,10 +905,13 @@ module Cataract
|
|
|
829
905
|
|
|
830
906
|
skip_ws_and_comments
|
|
831
907
|
|
|
832
|
-
# 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,...)".
|
|
833
912
|
value_start = @_pos
|
|
834
|
-
important = false
|
|
835
913
|
in_quote = nil # nil, BYTE_SQUOTE, or BYTE_DQUOTE
|
|
914
|
+
paren_depth = 0
|
|
836
915
|
|
|
837
916
|
until eof?
|
|
838
917
|
byte = peek_byte
|
|
@@ -846,11 +925,17 @@ module Cataract
|
|
|
846
925
|
@_pos += 1
|
|
847
926
|
end
|
|
848
927
|
else
|
|
849
|
-
# Not in quote - check for terminators or quote start
|
|
850
|
-
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)
|
|
851
930
|
|
|
852
|
-
|
|
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
|
|
853
934
|
in_quote = byte
|
|
935
|
+
elsif byte == BYTE_LPAREN
|
|
936
|
+
paren_depth += 1
|
|
937
|
+
elsif byte == BYTE_RPAREN
|
|
938
|
+
paren_depth -= 1
|
|
854
939
|
end
|
|
855
940
|
end
|
|
856
941
|
|
|
@@ -861,36 +946,7 @@ module Cataract
|
|
|
861
946
|
value.strip!
|
|
862
947
|
|
|
863
948
|
# Check for !important (byte-by-byte, no regexp)
|
|
864
|
-
|
|
865
|
-
# Scan backwards to find !important
|
|
866
|
-
i = value.bytesize - 1
|
|
867
|
-
# Skip trailing whitespace
|
|
868
|
-
while i >= 0
|
|
869
|
-
b = value.getbyte(i)
|
|
870
|
-
break unless b == BYTE_SPACE || b == BYTE_TAB
|
|
871
|
-
|
|
872
|
-
i -= 1
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
# Check for 'important' (9 chars)
|
|
876
|
-
if i >= 8 && value[(i - 8), 9] == 'important'
|
|
877
|
-
i -= 9
|
|
878
|
-
# Skip whitespace before 'important'
|
|
879
|
-
while i >= 0
|
|
880
|
-
b = value.getbyte(i)
|
|
881
|
-
break unless b == BYTE_SPACE || b == BYTE_TAB
|
|
882
|
-
|
|
883
|
-
i -= 1
|
|
884
|
-
end
|
|
885
|
-
# Check for '!'
|
|
886
|
-
if i >= 0 && value.getbyte(i) == BYTE_BANG
|
|
887
|
-
important = true
|
|
888
|
-
# Remove everything from '!' onwards (use byteslice and strip in-place)
|
|
889
|
-
value = value.byteslice(0, i)
|
|
890
|
-
value.strip!
|
|
891
|
-
end
|
|
892
|
-
end
|
|
893
|
-
end
|
|
949
|
+
important = extract_important!(value)
|
|
894
950
|
|
|
895
951
|
# Check for empty value (strict mode) - only if enabled to avoid overhead
|
|
896
952
|
if @_check_empty_values && value.empty?
|
|
@@ -911,6 +967,82 @@ module Cataract
|
|
|
911
967
|
declarations
|
|
912
968
|
end
|
|
913
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
|
+
|
|
914
1046
|
# Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
|
|
915
1047
|
# Translated from C: see ext/cataract/css_parser.c lines 962-1128
|
|
916
1048
|
def parse_at_rule
|
|
@@ -929,422 +1061,363 @@ module Cataract
|
|
|
929
1061
|
at_rule_name = byteslice_encoded(name_start, @_pos - name_start)
|
|
930
1062
|
|
|
931
1063
|
# Handle @charset specially - it's just @charset "value";
|
|
932
|
-
if at_rule_name == 'charset'
|
|
933
|
-
skip_ws_and_comments
|
|
934
|
-
# Read until semicolon
|
|
935
|
-
value_start = @_pos
|
|
936
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
937
|
-
@_pos += 1
|
|
938
|
-
end
|
|
939
|
-
|
|
940
|
-
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
941
|
-
charset_value.strip!
|
|
942
|
-
# Remove quotes
|
|
943
|
-
@charset = charset_value.delete('"\'')
|
|
944
|
-
|
|
945
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
946
|
-
return
|
|
947
|
-
end
|
|
1064
|
+
return parse_charset_at_rule if at_rule_name == 'charset'
|
|
948
1065
|
|
|
949
1066
|
# Handle @import - must come before rules (except @charset)
|
|
950
|
-
if at_rule_name == 'import'
|
|
951
|
-
# If we've already seen a rule, this @import is invalid
|
|
952
|
-
if @rules.size > 0
|
|
953
|
-
warn 'CSS @import ignored: @import must appear before all rules (found import after rules)'
|
|
954
|
-
# Skip to semicolon
|
|
955
|
-
while !eof? && peek_byte != BYTE_SEMICOLON
|
|
956
|
-
@_pos += 1
|
|
957
|
-
end
|
|
958
|
-
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
959
|
-
return
|
|
960
|
-
end
|
|
961
|
-
|
|
962
|
-
parse_import_statement
|
|
963
|
-
return
|
|
964
|
-
end
|
|
1067
|
+
return parse_import_at_rule if at_rule_name == 'import'
|
|
965
1068
|
|
|
966
1069
|
# Handle conditional group at-rules: @supports, @layer, @container, @scope
|
|
967
1070
|
# These behave like @media but don't affect media context
|
|
968
|
-
if AT_RULE_TYPES.include?(at_rule_name)
|
|
969
|
-
skip_ws_and_comments
|
|
970
|
-
|
|
971
|
-
# Remember start of condition for error reporting
|
|
972
|
-
condition_start = @_pos
|
|
1071
|
+
return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
|
|
973
1072
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
while !eof? && peek_byte != BYTE_LBRACE
|
|
977
|
-
condition_end = @_pos
|
|
978
|
-
@_pos += 1
|
|
979
|
-
end
|
|
980
|
-
|
|
981
|
-
return if eof? || peek_byte != BYTE_LBRACE
|
|
1073
|
+
# Handle @media specially - parse content and track in media_index
|
|
1074
|
+
return parse_media_at_rule if at_rule_name == 'media'
|
|
982
1075
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
css: @_css, pos: condition_start, type: :malformed_at_rule)
|
|
989
|
-
end
|
|
990
|
-
end
|
|
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
|
|
991
1081
|
|
|
992
|
-
|
|
1082
|
+
# Check for @font-face (contains <declaration-list>)
|
|
1083
|
+
return parse_font_face_at_rule(at_rule_start) if at_rule_name == 'font-face'
|
|
993
1084
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
|
997
1089
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
|
1002
1098
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
parent_media_sym: @_parent_media_sym,
|
|
1008
|
-
depth: @_depth + 1
|
|
1009
|
-
)
|
|
1010
|
-
|
|
1011
|
-
nested_result = nested_parser.parse
|
|
1012
|
-
|
|
1013
|
-
# Merge nested selector_lists with offsetted IDs
|
|
1014
|
-
list_id_offset = @_next_selector_list_id
|
|
1015
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
1016
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
1017
|
-
new_list_id = list_id + list_id_offset
|
|
1018
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
1019
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
1020
|
-
end
|
|
1021
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1022
|
-
end
|
|
1099
|
+
charset_value = byteslice_encoded(value_start, @_pos - value_start)
|
|
1100
|
+
charset_value.strip!
|
|
1101
|
+
# Remove quotes
|
|
1102
|
+
@charset = charset_value.delete('"\'')
|
|
1023
1103
|
|
|
1024
|
-
|
|
1025
|
-
|
|
1104
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
|
|
1105
|
+
end
|
|
1026
1106
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|
1036
1117
|
end
|
|
1037
|
-
|
|
1038
|
-
# Move position past the closing brace
|
|
1039
|
-
@_pos = block_end
|
|
1040
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1041
|
-
|
|
1118
|
+
@_pos += 1 if peek_byte == BYTE_SEMICOLON
|
|
1042
1119
|
return
|
|
1043
1120
|
end
|
|
1044
1121
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
skip_ws_and_comments
|
|
1122
|
+
parse_import_statement
|
|
1123
|
+
end
|
|
1048
1124
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
|
1054
1134
|
|
|
1055
|
-
|
|
1135
|
+
# Remember start of condition for error reporting
|
|
1136
|
+
condition_start = @_pos
|
|
1056
1137
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
|
1062
1144
|
|
|
1063
|
-
|
|
1064
|
-
# Keep media query exactly as written - parentheses are required per CSS spec
|
|
1065
|
-
child_media_string.strip!
|
|
1145
|
+
return if eof? || peek_byte != BYTE_LBRACE
|
|
1066
1146
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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)
|
|
1071
1153
|
end
|
|
1154
|
+
end
|
|
1072
1155
|
|
|
1073
|
-
|
|
1156
|
+
@_pos += 1 # skip '{'
|
|
1074
1157
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1158
|
+
# Find matching closing brace
|
|
1159
|
+
block_start = @_pos
|
|
1160
|
+
block_end = find_matching_brace(@_pos)
|
|
1078
1161
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
media_query = Cataract::MediaQuery.new(@_media_query_id_counter, media_type, media_conditions)
|
|
1084
|
-
@media_queries << media_query
|
|
1085
|
-
media_query_ids << @_media_query_id_counter
|
|
1086
|
-
@_media_query_id_counter += 1
|
|
1087
|
-
end
|
|
1088
|
-
|
|
1089
|
-
# If multiple queries, track them as a list for serialization
|
|
1090
|
-
if media_query_ids.size > 1
|
|
1091
|
-
@_media_query_lists[@_next_media_query_list_id] = media_query_ids
|
|
1092
|
-
@_next_media_query_list_id += 1
|
|
1093
|
-
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
|
|
1094
1166
|
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
+
)
|
|
1097
1174
|
|
|
1098
|
-
|
|
1099
|
-
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1175
|
+
nested_result = nested_parser.parse
|
|
1100
1176
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
combined_media_query_id = current_media_query_id
|
|
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)
|
|
1106
1181
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
raise SizeError, "Too many media queries: exceeded maximum of #{MAX_MEDIA_QUERIES}"
|
|
1112
|
-
end
|
|
1113
|
-
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
|
|
1114
1186
|
|
|
1115
|
-
|
|
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
|
|
1116
1194
|
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1195
|
+
# Find media query (up to opening brace)
|
|
1196
|
+
mq_start = @_pos
|
|
1197
|
+
return unless skip_to_opening_brace
|
|
1120
1198
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
|
1125
1204
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
nested_parser = Parser.new(
|
|
1130
|
-
byteslice_encoded(block_start, block_end - block_start),
|
|
1131
|
-
parser_options: @_parser_options,
|
|
1132
|
-
parent_media_sym: combined_media_sym,
|
|
1133
|
-
depth: @_depth + 1
|
|
1134
|
-
)
|
|
1135
|
-
|
|
1136
|
-
nested_result = nested_parser.parse
|
|
1137
|
-
|
|
1138
|
-
# Merge nested selector_lists with offsetted IDs
|
|
1139
|
-
list_id_offset = @_next_selector_list_id
|
|
1140
|
-
if nested_result[:_selector_lists] && !nested_result[:_selector_lists].empty?
|
|
1141
|
-
nested_result[:_selector_lists].each do |list_id, rule_ids|
|
|
1142
|
-
new_list_id = list_id + list_id_offset
|
|
1143
|
-
offsetted_rule_ids = rule_ids.map { |rid| rid + @_rule_id_counter }
|
|
1144
|
-
@_selector_lists[new_list_id] = offsetted_rule_ids
|
|
1145
|
-
end
|
|
1146
|
-
@_next_selector_list_id = list_id_offset + nested_result[:_selector_lists].size
|
|
1147
|
-
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!
|
|
1148
1208
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
new_mq = Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
|
|
1155
|
-
@media_queries << new_mq
|
|
1156
|
-
end
|
|
1157
|
-
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1158
|
-
end
|
|
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
|
|
1159
1214
|
|
|
1160
|
-
|
|
1161
|
-
if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
|
|
1162
|
-
nested_result[:_media_query_lists].each do |list_id, mq_ids|
|
|
1163
|
-
# Offset the list_id and media_query_ids
|
|
1164
|
-
new_list_id = list_id + @_next_media_query_list_id
|
|
1165
|
-
offsetted_mq_ids = mq_ids.map { |mq_id| mq_id + mq_id_offset }
|
|
1166
|
-
@_media_query_lists[new_list_id] = offsetted_mq_ids
|
|
1167
|
-
end
|
|
1168
|
-
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1169
|
-
end
|
|
1215
|
+
child_media_sym = child_media_string.to_sym
|
|
1170
1216
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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)
|
|
1174
1220
|
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
|
1229
|
+
end
|
|
1182
1230
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
# Combine nested media query with our media context
|
|
1190
|
-
if nested_mq && combined_media_query_id
|
|
1191
|
-
outer_mq = @media_queries[combined_media_query_id]
|
|
1192
|
-
if outer_mq
|
|
1193
|
-
# Combine media queries directly without string building
|
|
1194
|
-
combined_type, combined_conditions = combine_media_query_parts(outer_mq, nested_mq.conditions)
|
|
1195
|
-
combined_mq = Cataract::MediaQuery.new(@_media_query_id_counter, combined_type, combined_conditions)
|
|
1196
|
-
@media_queries << combined_mq
|
|
1197
|
-
rule.media_query_id = @_media_query_id_counter
|
|
1198
|
-
@_media_query_id_counter += 1
|
|
1199
|
-
else
|
|
1200
|
-
rule.media_query_id = nested_mq_id
|
|
1201
|
-
end
|
|
1202
|
-
else
|
|
1203
|
-
rule.media_query_id = nested_mq_id
|
|
1204
|
-
end
|
|
1205
|
-
elsif rule.respond_to?(:media_query_id=)
|
|
1206
|
-
# Assign the combined media_query_id if no media_query_id set
|
|
1207
|
-
# (applies to both Rule and AtRule)
|
|
1208
|
-
rule.media_query_id = combined_media_query_id
|
|
1209
|
-
end
|
|
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
|
|
1210
1236
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1237
|
+
# Use first query ID as the primary one for rules in this block
|
|
1238
|
+
current_media_query_id = media_query_ids.first
|
|
1213
1239
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
end
|
|
1240
|
+
# Combine with parent media context
|
|
1241
|
+
combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
|
|
1217
1242
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
|
1221
1248
|
|
|
1222
|
-
|
|
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}"
|
|
1254
|
+
end
|
|
1223
1255
|
end
|
|
1224
1256
|
|
|
1225
|
-
|
|
1226
|
-
is_keyframes = at_rule_name == 'keyframes' ||
|
|
1227
|
-
at_rule_name == '-webkit-keyframes' ||
|
|
1228
|
-
at_rule_name == '-moz-keyframes'
|
|
1229
|
-
|
|
1230
|
-
if is_keyframes
|
|
1231
|
-
# Build full selector string: "@keyframes fade"
|
|
1232
|
-
selector_start = at_rule_start # Points to '@'
|
|
1257
|
+
@_pos += 1 # skip '{'
|
|
1233
1258
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
end
|
|
1259
|
+
# Find matching closing brace
|
|
1260
|
+
block_start = @_pos
|
|
1261
|
+
block_end = find_matching_brace(@_pos)
|
|
1238
1262
|
|
|
1239
|
-
|
|
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
|
|
1240
1267
|
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
+
)
|
|
1247
1277
|
|
|
1248
|
-
|
|
1278
|
+
nested_result = nested_parser.parse
|
|
1249
1279
|
|
|
1250
|
-
|
|
1251
|
-
block_start = @_pos
|
|
1252
|
-
block_end = find_matching_brace(@_pos)
|
|
1280
|
+
list_id_offset = merge_nested_selector_lists(nested_result)
|
|
1253
1281
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
1257
1289
|
end
|
|
1258
|
-
|
|
1259
|
-
# Parse keyframe blocks as rules (0%/from/to etc)
|
|
1260
|
-
# Create a nested parser context
|
|
1261
|
-
nested_parser = Parser.new(
|
|
1262
|
-
byteslice_encoded(block_start, block_end - block_start),
|
|
1263
|
-
parser_options: @_parser_options,
|
|
1264
|
-
depth: @_depth + 1
|
|
1265
|
-
)
|
|
1266
|
-
nested_result = nested_parser.parse
|
|
1267
|
-
content = nested_result[:rules]
|
|
1268
|
-
|
|
1269
|
-
# Move position past the closing brace
|
|
1270
|
-
@_pos = block_end
|
|
1271
|
-
# The closing brace should be at block_end
|
|
1272
|
-
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1273
|
-
|
|
1274
|
-
# Get rule ID and increment
|
|
1275
|
-
rule_id = @_rule_id_counter
|
|
1276
|
-
@_rule_id_counter += 1
|
|
1277
|
-
|
|
1278
|
-
# Create AtRule with nested rules
|
|
1279
|
-
at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
|
|
1280
|
-
@rules << at_rule
|
|
1281
|
-
|
|
1282
|
-
return
|
|
1290
|
+
@_media_query_id_counter += nested_result[:media_queries].size
|
|
1283
1291
|
end
|
|
1284
1292
|
|
|
1285
|
-
#
|
|
1286
|
-
if
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
@_pos += 1
|
|
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
|
|
1293
1300
|
end
|
|
1301
|
+
@_next_media_query_list_id += nested_result[:_media_query_lists].size
|
|
1302
|
+
end
|
|
1294
1303
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
|
1301
1335
|
end
|
|
1302
|
-
|
|
1336
|
+
end
|
|
1303
1337
|
|
|
1304
|
-
|
|
1338
|
+
# Move position past the closing brace
|
|
1339
|
+
@_pos = block_end
|
|
1340
|
+
@_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
|
|
1341
|
+
end
|
|
1305
1342
|
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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
|
|
1309
1359
|
|
|
1310
|
-
|
|
1311
|
-
|
|
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]
|
|
1312
1369
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
|
1317
1374
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1375
|
+
# Get rule ID and increment
|
|
1376
|
+
rule_id = @_rule_id_counter
|
|
1377
|
+
@_rule_id_counter += 1
|
|
1321
1378
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
|
1325
1383
|
|
|
1326
|
-
|
|
1327
|
-
|
|
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?
|
|
1328
1391
|
|
|
1329
|
-
#
|
|
1330
|
-
|
|
1331
|
-
|
|
1392
|
+
# Find matching closing brace
|
|
1393
|
+
decl_start = @_pos
|
|
1394
|
+
decl_end = find_matching_brace(@_pos)
|
|
1332
1395
|
|
|
1333
|
-
#
|
|
1334
|
-
|
|
1335
|
-
@_pos += 1
|
|
1336
|
-
end
|
|
1396
|
+
# Parse declarations
|
|
1397
|
+
content = parse_declarations_block(decl_start, decl_end)
|
|
1337
1398
|
|
|
1338
|
-
|
|
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
|
|
1339
1403
|
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
selector_end -= 1
|
|
1344
|
-
end
|
|
1345
|
-
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
|
|
1346
1407
|
|
|
1347
|
-
|
|
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?
|
|
1348
1421
|
|
|
1349
1422
|
# Parse declarations
|
|
1350
1423
|
declarations = parse_declarations
|