cataract 0.4.0 → 0.5.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.
@@ -13,6 +13,8 @@
13
13
  * - :not() doesn't count itself, but its content does
14
14
  * - Legacy pseudo-elements with single colon (:before) count as pseudo-elements
15
15
  * - Universal selector (*) has zero specificity
16
+ * - A namespace prefix (ns|E, *|E, |E) contributes zero itself - only the
17
+ * local name after '|' counts, same as a bare "E" would
16
18
  */
17
19
 
18
20
  #include "cataract.h"
@@ -188,14 +190,23 @@ VALUE calculate_specificity(VALUE self, VALUE selector_string) {
188
190
  continue;
189
191
  }
190
192
 
191
- // Type selector (element name): div, span, etc.
193
+ // Type selector (element name): div, span, etc. - or a namespace
194
+ // prefix (css-namespaces-3 qname grammar: prefix? '|' ident). A
195
+ // prefix contributes nothing itself; only the local name after '|'
196
+ // is the real type selector, so don't count it here - just skip
197
+ // past it and the separator and let the next iteration count the
198
+ // local name instead.
192
199
  if ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z')) {
193
- element_count++;
194
200
  // Skip the identifier
195
201
  while (p < pe && ((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
196
202
  (*p >= '0' && *p <= '9') || *p == '-' || *p == '_')) {
197
203
  p++;
198
204
  }
205
+ if (p < pe && *p == '|') {
206
+ p++; // skip namespace separator - prefix itself doesn't count
207
+ continue;
208
+ }
209
+ element_count++;
199
210
  continue;
200
211
  }
201
212
 
@@ -8,7 +8,6 @@ module Cataract
8
8
  # The content field varies by at-rule type:
9
9
  # - `@keyframes`: Array of Rule (keyframe percentage blocks like "0%", "100%")
10
10
  # - `@font-face`: Array of Declaration (font property declarations)
11
- # - `@supports`: Array of Rule (conditional rules)
12
11
  #
13
12
  # @example Parse @keyframes
14
13
  # css = "@keyframes fade { 0% { opacity: 0; } 100% { opacity: 1; } }"
@@ -29,7 +28,10 @@ module Cataract
29
28
  # @attr [Array<Rule>, Array<Declaration>] content Nested rules or declarations
30
29
  # @attr [nil] specificity Always nil for at-rules (they don't have CSS specificity)
31
30
  # @attr [Integer, nil] media_query_id ID of MediaQuery if inside @media block, nil otherwise
32
- AtRule = Struct.new(:id, :selector, :content, :specificity, :media_query_id) unless const_defined?(:AtRule)
31
+ # @attr [Integer, nil] conditional_group_id ID of ConditionalGroup if inside @supports/@layer/@container/@scope, nil otherwise
32
+ unless const_defined?(:AtRule)
33
+ AtRule = Struct.new(:id, :selector, :content, :specificity, :media_query_id, :conditional_group_id)
34
+ end
33
35
 
34
36
  class AtRule
35
37
  # Check if this is a selector-based rule (vs an at-rule like @keyframes).
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # ConditionalGroup represents a CSS "conditional group" at-rule wrapper -
5
+ # @supports, @layer, @container, or @scope.
6
+ #
7
+ # Like MediaQuery, these are stored in the Stylesheet and referenced by
8
+ # Rules/AtRules via conditional_group_id, so the rules they wrap stay flat
9
+ # and queryable in Stylesheet#rules while the wrapper's own name/condition
10
+ # (and nesting inside other conditional groups, via parent_id) survive for
11
+ # round-trip serialization. Cataract never evaluates the condition/name
12
+ # (no feature-query or container-query matching) - it's kept as opaque
13
+ # text.
14
+ #
15
+ # @example Access conditional-group properties
16
+ # group = ConditionalGroup.new(0, :supports, nil, '(display: grid)', nil)
17
+ # group.id #=> 0
18
+ # group.type #=> :supports
19
+ # group.condition #=> "(display: grid)"
20
+ #
21
+ # @attr [Integer] id Unique identifier for this conditional group within the stylesheet
22
+ # @attr [Symbol] type At-rule kind (:supports, :layer, :container, :scope)
23
+ # @attr [String, nil] name Named form (e.g. `@layer utilities`, `@container sidebar`), nil if unnamed
24
+ # @attr [String, nil] condition Opaque condition/prelude text (feature query, container query, scope root/limit), nil if none
25
+ # @attr [Integer, nil] parent_id Id of the enclosing ConditionalGroup, or nil if not nested inside another one
26
+ ConditionalGroup = Struct.new(:id, :type, :name, :condition, :parent_id) do
27
+ # Create a ConditionalGroup with keyword arguments for readability.
28
+ #
29
+ # @param id [Integer] Unique ID for this conditional group
30
+ # @param type [Symbol] At-rule kind (:supports, :layer, :container, :scope)
31
+ # @param name [String, nil] Named form, if any
32
+ # @param condition [String, nil] Opaque condition/prelude text, if any
33
+ # @param parent_id [Integer, nil] Enclosing ConditionalGroup's id, if nested
34
+ # @return [ConditionalGroup] New conditional group instance
35
+ #
36
+ # @example
37
+ # ConditionalGroup.make(id: 0, type: :supports, condition: '(display: grid)')
38
+ def self.make(id:, type:, name: nil, condition: nil, parent_id: nil)
39
+ new(id, type, name, condition, parent_id)
40
+ end
41
+
42
+ # Compare conditional groups for equality based on type, name, condition,
43
+ # and nesting. IDs are not considered since they're internal identifiers.
44
+ #
45
+ # @param other [Object] Object to compare with
46
+ # @return [Boolean] true if conditional groups match
47
+ def ==(other)
48
+ case other
49
+ when ConditionalGroup
50
+ type == other.type && name == other.name && condition == other.condition
51
+ else
52
+ false
53
+ end
54
+ end
55
+ alias_method :eql?, :==
56
+
57
+ # Generate hash code for this conditional group.
58
+ #
59
+ # @return [Integer] hash code
60
+ def hash
61
+ [self.class, type, name, condition].hash
62
+ end
63
+
64
+ # Get detailed inspection string.
65
+ #
66
+ # @return [String] Inspection string
67
+ def inspect
68
+ "#<ConditionalGroup id=#{id} type=#{type.inspect} name=#{name.inspect} " \
69
+ "condition=#{condition.inspect} parent_id=#{parent_id.inspect}>"
70
+ end
71
+ end
72
+ end
@@ -19,6 +19,7 @@ require_relative 'declaration'
19
19
  require_relative 'rule'
20
20
  require_relative 'at_rule'
21
21
  require_relative 'media_query'
22
+ require_relative 'conditional_group'
22
23
  require_relative 'import_statement'
23
24
 
24
25
  require_relative 'native_extension'
@@ -438,6 +438,11 @@ module Cataract
438
438
  # All rules being merged have the same media_query_id (they were grouped by it)
439
439
  media_query_id = rules.first.media_query_id
440
440
 
441
+ # Extract conditional_group_id from first rule in group (mirrors
442
+ # media_query_id above - not part of the grouping key, but should
443
+ # be uniform in practice for any group actually worth merging)
444
+ conditional_group_id = rules.first.conditional_group_id
445
+
441
446
  # Create merged rule
442
447
  Rule.new(
443
448
  0, # ID will be updated later
@@ -447,7 +452,8 @@ module Cataract
447
452
  nil, # No parent after flattening
448
453
  nil, # No nesting style after flattening
449
454
  selector_list_id, # Preserve if all rules share same ID
450
- media_query_id # Preserve media context
455
+ media_query_id, # Preserve media context
456
+ conditional_group_id # Preserve conditional-group context
451
457
  )
452
458
  end
453
459
 
@@ -61,7 +61,8 @@ module Cataract
61
61
  true
62
62
  end
63
63
 
64
- def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil, depth: 0)
64
+ def initialize(css_string, parser_options: {}, parent_media_sym: nil, parent_media_query_id: nil,
65
+ parent_conditional_group_id: nil, depth: 0)
65
66
  # Type validation
66
67
  raise TypeError, "css_string must be a String, got #{css_string.class}" unless css_string.is_a?(String)
67
68
 
@@ -71,6 +72,7 @@ module Cataract
71
72
  @_len = @_css.bytesize
72
73
  @_parent_media_sym = parent_media_sym
73
74
  @_parent_media_query_id = parent_media_query_id
75
+ @_parent_conditional_group_id = parent_conditional_group_id
74
76
  @_depth = depth # Current recursion depth (passed from parent parser)
75
77
 
76
78
  # Private: Parser options with defaults
@@ -120,6 +122,7 @@ module Cataract
120
122
  @_media_query_id_counter = 0 # Next MediaQuery ID (0-indexed)
121
123
  @_next_selector_list_id = 0 # Counter for selector list IDs
122
124
  @_next_media_query_list_id = 0 # Counter for media query list IDs
125
+ @_conditional_group_id_counter = 0 # Next ConditionalGroup ID (0-indexed)
123
126
  @_rule_id_counter = 0 # Next rule ID (0-indexed)
124
127
  @_media_query_count = 0 # Safety limit
125
128
 
@@ -127,6 +130,7 @@ module Cataract
127
130
  @rules = [] # Flat array of Rule structs
128
131
  @media_queries = [] # Array of MediaQuery objects
129
132
  @media_index = {} # Symbol => Array of rule IDs (for backwards compat/caching)
133
+ @conditional_groups = [] # Array of ConditionalGroup objects (@supports/@layer/@container/@scope)
130
134
  @imports = [] # Array of ImportStatement structs
131
135
  @charset = nil # @charset declaration
132
136
 
@@ -261,6 +265,7 @@ module Cataract
261
265
  media_queries: @media_queries,
262
266
  _selector_lists: @_selector_lists,
263
267
  _media_query_lists: @_media_query_lists,
268
+ conditional_groups: @conditional_groups,
264
269
  imports: @imports,
265
270
  charset: @charset,
266
271
  _has_nesting: @_has_nesting
@@ -743,7 +748,8 @@ module Cataract
743
748
  parent_rule_id,
744
749
  nil, # nesting_style (nil for @media nesting)
745
750
  nil, # selector_list_id
746
- nested_media_query_id # media_query_id
751
+ nested_media_query_id, # media_query_id
752
+ @_parent_conditional_group_id # conditional_group_id (inherited, not created here)
747
753
  )
748
754
 
749
755
  # Mark that we have nesting
@@ -1035,17 +1041,53 @@ module Cataract
1035
1041
  @_has_nesting ||= nested_result[:_has_nesting] # rubocop:disable Naming/MemoizedInstanceVariableName -- not memoization, propagating a flag from the nested parse
1036
1042
  end
1037
1043
 
1044
+ # Merge nested ConditionalGroup objects into @conditional_groups,
1045
+ # renumbering ids and rebasing each one's own parent_id.
1046
+ #
1047
+ # A group's parent_id is either (a) another group ALSO present in
1048
+ # nested_result[:conditional_groups] - a local sibling created within
1049
+ # the same nested Parser, needing the same renumbering - or (b) the
1050
+ # external parent context that was passed into that nested Parser's
1051
+ # constructor, which already refers to a real id in THIS parser's own
1052
+ # numbering scheme and must NOT be touched. Both a local sibling's id
1053
+ # and the external context can be small, overlapping numbers (each
1054
+ # fresh Parser instance's own ids start at 0), so this is resolved by
1055
+ # membership in old_to_new (built as we go, since parents are always
1056
+ # discovered before their children) rather than by comparing values.
1057
+ #
1058
+ # @param nested_result [Hash] Result hash from a nested Parser#parse call
1059
+ # @return [Hash{Integer => Integer}] old (local) id => new (merged) id,
1060
+ # for callers to also remap rules' conditional_group_id with
1061
+ def merge_nested_conditional_groups(nested_result)
1062
+ old_to_new = {}
1063
+ return old_to_new unless nested_result[:conditional_groups] && !nested_result[:conditional_groups].empty?
1064
+
1065
+ nested_result[:conditional_groups].each do |group|
1066
+ new_id = @_conditional_group_id_counter
1067
+ new_parent_id = old_to_new.key?(group.parent_id) ? old_to_new[group.parent_id] : group.parent_id
1068
+ old_to_new[group.id] = new_id
1069
+ @conditional_groups << Cataract::ConditionalGroup.new(new_id, group.type, group.name, group.condition, new_parent_id)
1070
+ @_conditional_group_id_counter += 1
1071
+ end
1072
+
1073
+ old_to_new
1074
+ end
1075
+
1038
1076
  # Parse at-rule (@media, @supports, @charset, @keyframes, @font-face, etc)
1039
1077
  # Translated from C: see ext/cataract/css_parser.c lines 962-1128
1040
1078
  def parse_at_rule
1041
1079
  at_rule_start = @_pos # Points to '@'
1042
1080
  @_pos += 1 # skip '@'
1043
1081
 
1044
- # Find end of at-rule name (stop at whitespace or opening brace)
1082
+ # Find end of at-rule name (stop at whitespace, '{', '(', ';', or a
1083
+ # quote - an ident naturally terminates at any of these even with
1084
+ # no space, e.g. minified "@supports(display:grid){" or
1085
+ # "@media(...){" or "@namespace"http://...";)
1045
1086
  name_start = @_pos
1046
1087
  until eof?
1047
1088
  byte = peek_byte
1048
- break if whitespace?(byte) || byte == BYTE_LBRACE
1089
+ break if whitespace?(byte) || byte == BYTE_LBRACE || byte == BYTE_LPAREN || byte == BYTE_SEMICOLON ||
1090
+ byte == BYTE_DQUOTE || byte == BYTE_SQUOTE
1049
1091
 
1050
1092
  @_pos += 1
1051
1093
  end
@@ -1058,9 +1100,14 @@ module Cataract
1058
1100
  # Handle @import - must come before rules (except @charset)
1059
1101
  return parse_import_at_rule if at_rule_name == 'import'
1060
1102
 
1103
+ # Handle @namespace - purely a statement (never a block), so it's
1104
+ # handled here rather than falling through to the conditional-group
1105
+ # or unknown-at-rule paths below.
1106
+ return parse_namespace_at_rule(at_rule_start) if at_rule_name == 'namespace'
1107
+
1061
1108
  # Handle conditional group at-rules: @supports, @layer, @container, @scope
1062
1109
  # These behave like @media but don't affect media context
1063
- return parse_conditional_group_at_rule(at_rule_name) if AT_RULE_TYPES.include?(at_rule_name)
1110
+ return parse_conditional_group_at_rule(at_rule_name, at_rule_start) if AT_RULE_TYPES.include?(at_rule_name)
1064
1111
 
1065
1112
  # Handle @media specially - parse content and track in media_index
1066
1113
  return parse_media_at_rule if at_rule_name == 'media'
@@ -1114,35 +1161,137 @@ module Cataract
1114
1161
  parse_import_statement
1115
1162
  end
1116
1163
 
1164
+ # @namespace [prefix] (url(...) | "..."); - declares a namespace
1165
+ # prefix (or, with no prefix, the default namespace) mapped to a
1166
+ # URI. Per css-namespaces-3 this is purely a statement - it never
1167
+ # has a block - so, like @layer's statement form, it's captured
1168
+ # verbatim as an AtRule with content nil rather than needing any
1169
+ # dedicated struct. Multiple @namespace rules are legal (one
1170
+ # default + any number of prefixed ones); nothing here computes
1171
+ # resolution between them, callers read the prefix/URI back out of
1172
+ # the raw text.
1173
+ def parse_namespace_at_rule(at_rule_start)
1174
+ skip_ws_and_comments
1175
+ value_start = @_pos
1176
+
1177
+ # Scan to the terminating ';', honoring quoted strings and paren
1178
+ # depth so a URI given as url(...) can't have a stray ';' inside
1179
+ # it (e.g. a data: URI) end the statement early.
1180
+ in_quote = nil
1181
+ paren_depth = 0
1182
+
1183
+ until eof?
1184
+ byte = peek_byte
1185
+
1186
+ if in_quote
1187
+ if byte == in_quote
1188
+ in_quote = nil
1189
+ elsif byte == BYTE_BACKSLASH && @_pos + 1 < @_len
1190
+ @_pos += 1
1191
+ end
1192
+ else
1193
+ break if byte == BYTE_SEMICOLON && paren_depth == 0
1194
+
1195
+ if byte == BYTE_SQUOTE || byte == BYTE_DQUOTE # rubocop:disable Style/CaseLikeIf
1196
+ in_quote = byte
1197
+ elsif byte == BYTE_LPAREN
1198
+ paren_depth += 1
1199
+ elsif byte == BYTE_RPAREN
1200
+ paren_depth -= 1
1201
+ end
1202
+ end
1203
+
1204
+ @_pos += 1
1205
+ end
1206
+
1207
+ return if eof? # malformed: no terminating ';'
1208
+
1209
+ content_end = @_pos
1210
+ content_end -= 1 while content_end > value_start && whitespace?(@_css.getbyte(content_end - 1))
1211
+ @_pos += 1 # skip ';'
1212
+
1213
+ return if content_end <= value_start # "@namespace;" - nothing to declare
1214
+
1215
+ selector = byteslice_encoded(at_rule_start, content_end - at_rule_start)
1216
+
1217
+ rule_id = @_rule_id_counter
1218
+ @_rule_id_counter += 1
1219
+ @rules << AtRule.new(rule_id, selector, nil, nil, @_parent_media_query_id, @_parent_conditional_group_id)
1220
+ end
1221
+
1222
+ # Split a raw @container prelude into [name, condition]:
1223
+ # "sidebar (min-width: 400px)" -> ["sidebar", "(min-width: 400px)"]
1224
+ # "(min-width: 400px)" -> [nil, "(min-width: 400px)"]
1225
+ # "sidebar" -> ["sidebar", nil]
1226
+ # "not (min-width: 400px)" -> [nil, "not (min-width: 400px)"] (anonymous - "not" isn't a valid container-query name ident)
1227
+ # "sidebar not (min-width: 400px)" -> ["sidebar", "not (min-width: 400px)"]
1228
+ def split_container_prelude(raw)
1229
+ return [nil, raw] if raw.start_with?('(')
1230
+
1231
+ # (start, length) form avoids allocating a Range, unlike raw[(i+1)..]
1232
+ space_idx = raw.index(' ')
1233
+ first_word = space_idx ? raw[0, space_idx] : raw
1234
+
1235
+ return [nil, raw] if first_word.casecmp?('not') # allocation-free vs first_word.downcase == 'not'
1236
+ return [first_word, nil] unless space_idx
1237
+
1238
+ # Caller already stripped raw's own leading/trailing whitespace, so
1239
+ # only a leading gap between name and condition can remain here -
1240
+ # lstrip! (in place, on this fresh slice) instead of strip (which
1241
+ # would allocate a second string to also re-check the trailing end)
1242
+ rest = raw[space_idx + 1, raw.length]
1243
+ rest.lstrip!
1244
+ [first_word, rest.empty? ? nil : rest]
1245
+ end
1246
+
1117
1247
  # @supports, @layer, @container, @scope - behave like @media but don't
1118
- # affect media context, so unlike parse_media_at_rule there's no media
1119
- # query/media_query_id merging to do on top of the shared nested-parser
1120
- # result merge.
1248
+ # affect media context (so no combining, unlike parse_media_at_rule -
1249
+ # a nested @media's MediaQuery objects just need to be re-housed in
1250
+ # this parser's own @media_queries with offset ids, same as any other
1251
+ # nested-parser result).
1252
+ #
1253
+ # @supports, @container, and @layer's block form build a real
1254
+ # ConditionalGroup and tag their child rules with a
1255
+ # conditional_group_id - @scope still just recurses and passes the
1256
+ # parent context through unchanged (its condition/name text is only
1257
+ # used for the missing-condition validation below, same as before).
1258
+ # It gets its own faithful representation in its own follow-up bead.
1259
+ #
1260
+ # @layer also has a statement form (`@layer name, name;`, no block at
1261
+ # all) - handled separately below, since there are no rules to wrap.
1121
1262
  #
1122
1263
  # @param at_rule_name [String] The at-rule name (e.g. "supports"), used
1123
1264
  # to decide whether a condition is required and for error messages
1124
- def parse_conditional_group_at_rule(at_rule_name)
1265
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@',
1266
+ # used to build a verbatim selector for the statement form
1267
+ def parse_conditional_group_at_rule(at_rule_name, at_rule_start)
1125
1268
  skip_ws_and_comments
1126
1269
 
1127
1270
  # Remember start of condition for error reporting
1128
1271
  condition_start = @_pos
1129
1272
 
1130
- # Skip to opening brace
1131
- condition_end = @_pos
1132
- while !eof? && peek_byte != BYTE_LBRACE
1133
- condition_end = @_pos
1273
+ # Skip to whichever terminator comes first: '{' (block form) or ';'
1274
+ # (statement form - only valid for @layer, but we still stop there
1275
+ # for the others rather than scanning straight through it into
1276
+ # whatever rule happens to follow)
1277
+ while !eof? && peek_byte != BYTE_LBRACE && peek_byte != BYTE_SEMICOLON
1134
1278
  @_pos += 1
1135
1279
  end
1136
1280
 
1137
- return if eof? || peek_byte != BYTE_LBRACE
1281
+ return if eof?
1282
+
1283
+ if peek_byte == BYTE_SEMICOLON
1284
+ parse_layer_statement(at_rule_start, condition_start) if at_rule_name == 'layer'
1285
+ @_pos += 1 # skip ';'
1286
+ return
1287
+ end
1288
+
1289
+ condition_str = byteslice_encoded(condition_start, @_pos - condition_start).strip
1138
1290
 
1139
1291
  # Validate condition (strict mode) - @supports, @container, @scope require conditions
1140
- if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope')
1141
- condition_str = byteslice_encoded(condition_start, condition_end - condition_start).strip
1142
- if condition_str.empty?
1143
- raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
1144
- css: @_css, pos: condition_start, type: :malformed_at_rule)
1145
- end
1292
+ if @_check_malformed_at_rules && (at_rule_name == 'supports' || at_rule_name == 'container' || at_rule_name == 'scope') && condition_str.empty?
1293
+ raise ParseError.new("Malformed @#{at_rule_name}: missing condition",
1294
+ css: @_css, pos: condition_start, type: :malformed_at_rule)
1146
1295
  end
1147
1296
 
1148
1297
  @_pos += 1 # skip '{'
@@ -1156,11 +1305,32 @@ module Cataract
1156
1305
  raise DepthError, "CSS nesting too deep: exceeded maximum depth of #{MAX_PARSE_DEPTH}"
1157
1306
  end
1158
1307
 
1308
+ # For @supports/@container/@layer, build a ConditionalGroup (nested
1309
+ # inside the enclosing one, if any) and tag every rule parsed
1310
+ # inside this block with it.
1311
+ child_conditional_group_id = @_parent_conditional_group_id
1312
+ if at_rule_name == 'layer' || !condition_str.empty?
1313
+ name, condition = case at_rule_name
1314
+ when 'supports' then [nil, condition_str]
1315
+ when 'container' then split_container_prelude(condition_str)
1316
+ when 'layer' then [condition_str.empty? ? nil : condition_str, nil]
1317
+ end
1318
+
1319
+ if condition || name || at_rule_name == 'layer'
1320
+ group = Cataract::ConditionalGroup.new(@_conditional_group_id_counter, at_rule_name.to_sym, name,
1321
+ condition, @_parent_conditional_group_id)
1322
+ @conditional_groups << group
1323
+ child_conditional_group_id = @_conditional_group_id_counter
1324
+ @_conditional_group_id_counter += 1
1325
+ end
1326
+ end
1327
+
1159
1328
  # Recursively parse block content (preserve parent media context)
1160
1329
  nested_parser = Parser.new(
1161
1330
  byteslice_encoded(block_start, block_end - block_start),
1162
1331
  parser_options: @_parser_options,
1163
1332
  parent_media_sym: @_parent_media_sym,
1333
+ parent_conditional_group_id: child_conditional_group_id,
1164
1334
  depth: @_depth + 1
1165
1335
  )
1166
1336
 
@@ -1169,13 +1339,69 @@ module Cataract
1169
1339
  # NOTE: We no longer build media_index during parse
1170
1340
  # It will be built from MediaQuery objects after import resolution
1171
1341
  list_id_offset = merge_nested_selector_lists(nested_result)
1172
- merge_nested_rules(nested_result, list_id_offset)
1342
+
1343
+ # Merge nested MediaQuery objects/lists with offsetted ids - a
1344
+ # @media discovered inside this block needs to land in OUR
1345
+ # @media_queries, not just the nested parser's own local one
1346
+ # (parse_media_at_rule does the equivalent for its own nesting).
1347
+ mq_id_offset = @_media_query_id_counter
1348
+ if nested_result[:media_queries] && !nested_result[:media_queries].empty?
1349
+ nested_result[:media_queries].each do |mq|
1350
+ @media_queries << Cataract::MediaQuery.new(mq.id + mq_id_offset, mq.type, mq.conditions)
1351
+ end
1352
+ @_media_query_id_counter += nested_result[:media_queries].size
1353
+ end
1354
+
1355
+ if nested_result[:_media_query_lists] && !nested_result[:_media_query_lists].empty?
1356
+ nested_result[:_media_query_lists].each do |list_id, mq_ids|
1357
+ new_list_id = list_id + @_next_media_query_list_id
1358
+ @_media_query_lists[new_list_id] = mq_ids.map { |mq_id| mq_id + mq_id_offset }
1359
+ end
1360
+ @_next_media_query_list_id += nested_result[:_media_query_lists].size
1361
+ end
1362
+
1363
+ # Merge nested ConditionalGroup objects (for @supports nested
1364
+ # inside @supports).
1365
+ old_to_new_cg_id = merge_nested_conditional_groups(nested_result)
1366
+
1367
+ merge_nested_rules(nested_result, list_id_offset) do |rule|
1368
+ rule.media_query_id += mq_id_offset if rule.media_query_id
1369
+ if rule.conditional_group_id.nil?
1370
+ rule.conditional_group_id = child_conditional_group_id if rule.respond_to?(:conditional_group_id=)
1371
+ elsif old_to_new_cg_id.key?(rule.conditional_group_id)
1372
+ rule.conditional_group_id = old_to_new_cg_id[rule.conditional_group_id]
1373
+ end
1374
+ # else: already an external reference (e.g. == child_conditional_group_id,
1375
+ # inherited via pass-through from an intermediate @media), leave unchanged
1376
+ end
1173
1377
 
1174
1378
  # Move position past the closing brace
1175
1379
  @_pos = block_end
1176
1380
  @_pos += 1 if @_pos < @_len && @_css.getbyte(@_pos) == BYTE_RBRACE
1177
1381
  end
1178
1382
 
1383
+ # @layer name, name; - the statement form declares layer order
1384
+ # without wrapping any rules, so unlike the block form there's
1385
+ # nothing to tag with a conditional_group_id. Represented as an
1386
+ # AtRule instead (mirrors @keyframes/@font-face's "content varies by
1387
+ # type" pattern), with content nil since there's no block to hold.
1388
+ # Called with @_pos at the ';' that terminates the statement.
1389
+ #
1390
+ # @param at_rule_start [Integer] Position of the at-rule's leading '@'
1391
+ # @param condition_start [Integer] Position where the name list
1392
+ # begins (right after "@layer" and any whitespace)
1393
+ def parse_layer_statement(at_rule_start, condition_start)
1394
+ content_end = @_pos
1395
+ content_end -= 1 while content_end > condition_start && whitespace?(@_css.getbyte(content_end - 1))
1396
+ return if content_end <= condition_start # "@layer;" / "@layer ;" - no names, nothing to declare
1397
+
1398
+ selector = byteslice_encoded(at_rule_start, content_end - at_rule_start)
1399
+
1400
+ rule_id = @_rule_id_counter
1401
+ @_rule_id_counter += 1
1402
+ @rules << AtRule.new(rule_id, selector, nil, nil, @_parent_media_query_id, @_parent_conditional_group_id)
1403
+ end
1404
+
1179
1405
  # @media - parses the media query, recurses into a nested Parser for the
1180
1406
  # block content (combining its media context with any parent), and
1181
1407
  # merges the result. Unlike parse_conditional_group_at_rule, this also
@@ -1259,11 +1485,15 @@ module Cataract
1259
1485
 
1260
1486
  # Parse the content with the combined media context
1261
1487
  # Note: We don't pass parent_media_query_id because MediaQuery IDs are local to each parser
1262
- # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs
1488
+ # The nested parser will create its own MediaQueries, which we'll merge with offsetted IDs.
1489
+ # parent_conditional_group_id IS passed through unchanged (@media doesn't create or affect
1490
+ # conditional-group context) so a @supports nested inside this @media computes the right
1491
+ # parent_id chain if this @media is itself nested inside an outer @supports.
1263
1492
  nested_parser = Parser.new(
1264
1493
  byteslice_encoded(block_start, block_end - block_start),
1265
1494
  parser_options: @_parser_options,
1266
1495
  parent_media_sym: combined_media_sym,
1496
+ parent_conditional_group_id: @_parent_conditional_group_id,
1267
1497
  depth: @_depth + 1
1268
1498
  )
1269
1499
 
@@ -1297,6 +1527,10 @@ module Cataract
1297
1527
  # Note: We no longer build media_index during parse
1298
1528
  # It will be built from MediaQuery objects after import resolution
1299
1529
 
1530
+ # Merge nested ConditionalGroup objects (for a @supports discovered
1531
+ # inside this @media block).
1532
+ old_to_new_cg_id = merge_nested_conditional_groups(nested_result)
1533
+
1300
1534
  merge_nested_rules(nested_result, list_id_offset) do |rule|
1301
1535
  # Update media_query_id if applicable (both Rule and AtRule can have media_query_id)
1302
1536
  if rule.media_query_id
@@ -1325,6 +1559,14 @@ module Cataract
1325
1559
  # (applies to both Rule and AtRule)
1326
1560
  rule.media_query_id = combined_media_query_id
1327
1561
  end
1562
+
1563
+ # @media doesn't create or affect conditional-group context. Only
1564
+ # remap an id that matches a group we just merged above (a
1565
+ # @supports nested inside this @media) - anything else (nil, or
1566
+ # an external reference) is left alone, for whichever conditional
1567
+ # group actually encloses this @media (if any) to assign at its
1568
+ # own merge step. Assigning it here too would double-process it.
1569
+ rule.conditional_group_id = old_to_new_cg_id[rule.conditional_group_id] if old_to_new_cg_id.key?(rule.conditional_group_id)
1328
1570
  end
1329
1571
 
1330
1572
  # Move position past the closing brace
@@ -1369,7 +1611,7 @@ module Cataract
1369
1611
  @_rule_id_counter += 1
1370
1612
 
1371
1613
  # Create AtRule with nested rules
1372
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1614
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id, @_parent_conditional_group_id)
1373
1615
  @rules << at_rule
1374
1616
  end
1375
1617
 
@@ -1398,7 +1640,7 @@ module Cataract
1398
1640
  @_rule_id_counter += 1
1399
1641
 
1400
1642
  # Create AtRule with declarations
1401
- at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id)
1643
+ at_rule = AtRule.new(rule_id, selector, content, nil, @_parent_media_query_id, @_parent_conditional_group_id)
1402
1644
  @rules << at_rule
1403
1645
  end
1404
1646