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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/CHANGELOG.md +8 -0
- data/ext/cataract/cataract.c +124 -11
- data/ext/cataract/cataract.h +13 -2
- data/ext/cataract/css_parser.c +298 -36
- data/ext/cataract/flatten.c +6 -2
- data/ext/cataract/specificity.c +13 -2
- data/lib/cataract/at_rule.rb +4 -2
- data/lib/cataract/conditional_group.rb +72 -0
- data/lib/cataract/native.rb +1 -0
- data/lib/cataract/pure/flatten.rb +7 -1
- data/lib/cataract/pure/parser.rb +266 -24
- data/lib/cataract/pure/serializer.rb +95 -8
- data/lib/cataract/pure/specificity.rb +10 -2
- data/lib/cataract/pure.rb +1 -0
- data/lib/cataract/rule.rb +8 -4
- data/lib/cataract/stylesheet.rb +67 -20
- data/lib/cataract/version.rb +1 -1
- metadata +3 -1
data/ext/cataract/specificity.c
CHANGED
|
@@ -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
|
|
data/lib/cataract/at_rule.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/cataract/native.rb
CHANGED
|
@@ -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
|
|
data/lib/cataract/pure/parser.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
1119
|
-
#
|
|
1120
|
-
#
|
|
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
|
-
|
|
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
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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?
|
|
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
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
|