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.
@@ -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.split(',')
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.split(',')
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, parse_declarations, and parse_declarations_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
- while pos < end_pos && @_css.getbyte(pos) != BYTE_SEMICOLON && @_css.getbyte(pos) != BYTE_RBRACE
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 = false
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
- def parse_selector
518
- start_pos = @_pos
519
-
520
- # Read until we find '{'
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 if eof?
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 quoted strings)
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 == BYTE_SEMICOLON || byte == BYTE_RBRACE
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
- if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE
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
- if value.bytesize >= 10
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
- # Skip to opening brace
975
- condition_end = @_pos
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
- # Validate condition (strict mode) - @supports, @container, @scope require conditions
984
- if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
985
- condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
986
- if condition_str.empty?
987
- raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
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
- @_pos += 1 # skip '{'
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
- # Find matching closing brace
995
- block_start = @_pos
996
- block_end = find_matching_brace(@_pos)
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
- # Check depth before recursing
999
- if @_depth + 1 > MAX_PARSE_DEPTH
1000
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1001
- end
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
- # Recursively parse block content (preserve parent media context)
1004
- nested_parser = Parser.new(
1005
- byteslice_encoded(block_start, block_end - block_start),
1006
- parser_options: @_parser_options,
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
- # NOTE: We no longer build media_index during parse
1025
- # It will be built from MediaQuery objects after import resolution
1104
+ @_pos += 1 if peek_byte == BYTE_SEMICOLON # consume semicolon
1105
+ end
1026
1106
 
1027
- # Add nested rules to main rules array
1028
- nested_result[:rules].each do |rule|
1029
- rule.id = @_rule_id_counter
1030
- # Update selector_list_id if applicable
1031
- if rule.is_a?(Rule) && rule.selector_list_id
1032
- rule.selector_list_id += list_id_offset
1033
- end
1034
- @_rule_id_counter += 1
1035
- @rules << rule
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
- # Handle @media specially - parse content and track in media_index
1046
- if at_rule_name == 'media'
1047
- skip_ws_and_comments
1122
+ parse_import_statement
1123
+ end
1048
1124
 
1049
- # Find media query (up to opening brace)
1050
- mq_start = @_pos
1051
- while !eof? && peek_byte != BYTE_LBRACE
1052
- @_pos += 1
1053
- end
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
- return if eof? || peek_byte != BYTE_LBRACE
1135
+ # Remember start of condition for error reporting
1136
+ condition_start = @_pos
1056
1137
 
1057
- mq_end = @_pos
1058
- # Trim trailing whitespace
1059
- while mq_end > mq_start && whitespace?(@_css.getbyte(mq_end - 1))
1060
- mq_end -= 1
1061
- end
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
- child_media_string = byteslice_encoded(mq_start, mq_end - mq_start)
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
- # Validate @media has a query (strict mode)
1068
- if @_check_malformed_at_rules && child_media_string.empty?
1069
- raise ParseError.new('Malformed @media: missing media query or condition',
1070
- css: @_css, pos: mq_start, type: :malformed_at_rule)
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
- child_media_sym = child_media_string.to_sym
1156
+ @_pos += 1 # skip '{'
1074
1157
 
1075
- # Split comma-separated media queries (e.g., "screen, print" -> ["screen", "print"])
1076
- # Per W3C spec, comma acts as logical OR - each query is independent
1077
- media_query_strings = child_media_string.split(',').map(&:strip)
1158
+ # Find matching closing brace
1159
+ block_start = @_pos
1160
+ block_end = find_matching_brace(@_pos)
1078
1161
 
1079
- # Create MediaQuery objects for each query in the list
1080
- media_query_ids = []
1081
- media_query_strings.each do |query_string|
1082
- media_type, media_conditions = parse_media_query_parts(query_string)
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
- # Use first query ID as the primary one for rules in this block
1096
- current_media_query_id = media_query_ids.first
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
- # Combine with parent media context
1099
- combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1175
+ nested_result = nested_parser.parse
1100
1176
 
1101
- # NOTE: @_parent_media_query_id is always nil here because top-level @media blocks
1102
- # create separate parsers without passing parent_media_query_id (see nested_parser creation below).
1103
- # MediaQuery combining for nested @media happens in parse_mixed_block instead.
1104
- # So this is just an alias to current_media_query_id.
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
- # Check media query limit
1108
- unless @media_index.key?(combined_media_sym)
1109
- @_media_query_count += 1
1110
- if @_media_query_count > MAX_MEDIA_QUERIES
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
- @_pos += 1 # skip '{'
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
- # Find matching closing brace
1118
- block_start = @_pos
1119
- block_end = find_matching_brace(@_pos)
1195
+ # Find media query (up to opening brace)
1196
+ mq_start = @_pos
1197
+ return unless skip_to_opening_brace
1120
1198
 
1121
- # Check depth before recursing
1122
- if @_depth + 1 > MAX_PARSE_DEPTH
1123
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1124
- 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
1125
1204
 
1126
- # Parse the content with the combined media context
1127
- # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1128
- # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
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
- # Merge nested MediaQuery objects with offsetted IDs
1150
- mq_id_offset = @_media_query_id_counter
1151
- if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1152
- nested_result[:media_queries].each do |mq|
1153
- # Create new MediaQuery with offsetted ID
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
- # Merge nested media_query_lists with offsetted IDs
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
- # Merge nested media_index into ours (for nested @media)
1172
- # Note: We no longer build media_index during parse
1173
- # It will be built from MediaQuery objects after import resolution
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
- # Add nested rules to main rules array
1176
- nested_result[:rules].each do |rule|
1177
- rule.id = @_rule_id_counter
1178
- # Update selector_list_id if applicable
1179
- if rule.is_a?(Rule) && rule.selector_list_id
1180
- rule.selector_list_id += list_id_offset
1181
- end
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
- # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1184
- if rule.media_query_id
1185
- # Nested parser assigned a media_query_id - need to combine with our context
1186
- nested_mq_id = rule.media_query_id + mq_id_offset
1187
- nested_mq = @media_queries[nested_mq_id]
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
- # NOTE: We no longer build media_index during parse
1212
- # It will be built from MediaQuery objects after import resolution
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
- @_rule_id_counter += 1
1215
- @rules << rule
1216
- end
1240
+ # Combine with parent media context
1241
+ combined_media_sym = combine_media_queries(@_parent_media_sym, child_media_sym)
1217
1242
 
1218
- # Move position past the closing brace
1219
- @_pos = block_end
1220
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
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
- return
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
- # Check for @keyframes (contains <rule-list>)
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
- # Skip to opening brace
1235
- while !eof? && peek_byte != BYTE_LBRACE
1236
- @_pos += 1
1237
- end
1259
+ # Find matching closing brace
1260
+ block_start = @_pos
1261
+ block_end = find_matching_brace(@_pos)
1238
1262
 
1239
- return if eof? || peek_byte != BYTE_LBRACE
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
- selector_end = @_pos
1242
- # Trim trailing whitespace
1243
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1244
- selector_end -= 1
1245
- end
1246
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
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
- @_pos += 1 # skip '{'
1278
+ nested_result = nested_parser.parse
1249
1279
 
1250
- # Find matching closing brace
1251
- block_start = @_pos
1252
- block_end = find_matching_brace(@_pos)
1280
+ list_id_offset = merge_nested_selector_lists(nested_result)
1253
1281
 
1254
- # Check depth before recursing
1255
- if @_depth + 1 > MAX_PARSE_DEPTH
1256
- raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
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
- # Check for @font-face (contains <declaration-list>)
1286
- if at_rule_name == 'font-face'
1287
- # Build selector string: "@font-face"
1288
- selector_start = at_rule_start # Points to '@'
1289
-
1290
- # Skip to opening brace
1291
- while !eof? && peek_byte != BYTE_LBRACE
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
- return if eof? || peek_byte != BYTE_LBRACE
1296
-
1297
- selector_end = @_pos
1298
- # Trim trailing whitespace
1299
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
1300
- selector_end -= 1
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
- selector = byteslice_encoded(selector_start, selector_end - selector_start)
1336
+ end
1303
1337
 
1304
- @_pos += 1 # skip '{'
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
- # Find matching closing brace
1307
- decl_start = @_pos
1308
- decl_end = find_matching_brace(@_pos)
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
- # Parse declarations
1311
- content = parse_declarations_block(decl_start, decl_end)
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
- # Move position past the closing brace
1314
- @_pos = decl_end
1315
- # The closing brace should be at decl_end
1316
- @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
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
- # Get rule ID and increment
1319
- rule_id = @_rule_id_counter
1320
- @_rule_id_counter += 1
1375
+ # Get rule ID and increment
1376
+ rule_id = @_rule_id_counter
1377
+ @_rule_id_counter += 1
1321
1378
 
1322
- # Create AtRule with declarations
1323
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1324
- @rules << at_rule
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
- return
1327
- end
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
- # Unknown at-rule (@property, @page, @counter-style, etc.)
1330
- # Treat as a regular selector-based rule with declarations
1331
- selector_start = at_rule_start # Points to '@'
1392
+ # Find matching closing brace
1393
+ decl_start = @_pos
1394
+ decl_end = find_matching_brace(@_pos)
1332
1395
 
1333
- # Skip to opening brace
1334
- until eof? || peek_byte == BYTE_LBRACE # Save a not_opt instruction: while !eof? && peek_byte != BYTE_LBRACE
1335
- @_pos += 1
1336
- end
1396
+ # Parse declarations
1397
+ content = parse_declarations_block(decl_start, decl_end)
1337
1398
 
1338
- return if eof? || peek_byte != BYTE_LBRACE
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
- selector_end = @_pos
1341
- # Trim trailing whitespace
1342
- while selector_end > selector_start && whitespace?(@_css.getbyte(selector_end - 1))
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
- @_pos += 1 # skip '{'
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