cataract 0.3.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.
@@ -10,1341 +10,1362 @@
10
10
  # which is part of the public API. The main public API is Cataract.flatten.
11
11
 
12
12
  module Cataract
13
- module Flatten
14
- # Property name constants (US-ASCII for merge output)
15
- PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
16
- PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
17
- PROP_MARGIN_RIGHT = 'margin-right'.encode(Encoding::US_ASCII).freeze
18
- PROP_MARGIN_BOTTOM = 'margin-bottom'.encode(Encoding::US_ASCII).freeze
19
- PROP_MARGIN_LEFT = 'margin-left'.encode(Encoding::US_ASCII).freeze
20
-
21
- PROP_PADDING = 'padding'.encode(Encoding::US_ASCII).freeze
22
- PROP_PADDING_TOP = 'padding-top'.encode(Encoding::US_ASCII).freeze
23
- PROP_PADDING_RIGHT = 'padding-right'.encode(Encoding::US_ASCII).freeze
24
- PROP_PADDING_BOTTOM = 'padding-bottom'.encode(Encoding::US_ASCII).freeze
25
- PROP_PADDING_LEFT = 'padding-left'.encode(Encoding::US_ASCII).freeze
26
-
27
- PROP_BORDER = 'border'.encode(Encoding::US_ASCII).freeze
28
- PROP_BORDER_WIDTH = 'border-width'.encode(Encoding::US_ASCII).freeze
29
- PROP_BORDER_STYLE = 'border-style'.encode(Encoding::US_ASCII).freeze
30
- PROP_BORDER_COLOR = 'border-color'.encode(Encoding::US_ASCII).freeze
31
-
32
- PROP_BORDER_TOP = 'border-top'.encode(Encoding::US_ASCII).freeze
33
- PROP_BORDER_RIGHT = 'border-right'.encode(Encoding::US_ASCII).freeze
34
- PROP_BORDER_BOTTOM = 'border-bottom'.encode(Encoding::US_ASCII).freeze
35
- PROP_BORDER_LEFT = 'border-left'.encode(Encoding::US_ASCII).freeze
36
-
37
- PROP_BORDER_TOP_WIDTH = 'border-top-width'.encode(Encoding::US_ASCII).freeze
38
- PROP_BORDER_RIGHT_WIDTH = 'border-right-width'.encode(Encoding::US_ASCII).freeze
39
- PROP_BORDER_BOTTOM_WIDTH = 'border-bottom-width'.encode(Encoding::US_ASCII).freeze
40
- PROP_BORDER_LEFT_WIDTH = 'border-left-width'.encode(Encoding::US_ASCII).freeze
41
-
42
- PROP_BORDER_TOP_STYLE = 'border-top-style'.encode(Encoding::US_ASCII).freeze
43
- PROP_BORDER_RIGHT_STYLE = 'border-right-style'.encode(Encoding::US_ASCII).freeze
44
- PROP_BORDER_BOTTOM_STYLE = 'border-bottom-style'.encode(Encoding::US_ASCII).freeze
45
- PROP_BORDER_LEFT_STYLE = 'border-left-style'.encode(Encoding::US_ASCII).freeze
46
-
47
- PROP_BORDER_TOP_COLOR = 'border-top-color'.encode(Encoding::US_ASCII).freeze
48
- PROP_BORDER_RIGHT_COLOR = 'border-right-color'.encode(Encoding::US_ASCII).freeze
49
- PROP_BORDER_BOTTOM_COLOR = 'border-bottom-color'.encode(Encoding::US_ASCII).freeze
50
- PROP_BORDER_LEFT_COLOR = 'border-left-color'.encode(Encoding::US_ASCII).freeze
51
-
52
- PROP_FONT = 'font'.encode(Encoding::US_ASCII).freeze
53
- PROP_FONT_STYLE = 'font-style'.encode(Encoding::US_ASCII).freeze
54
- PROP_FONT_VARIANT = 'font-variant'.encode(Encoding::US_ASCII).freeze
55
- PROP_FONT_WEIGHT = 'font-weight'.encode(Encoding::US_ASCII).freeze
56
- PROP_FONT_SIZE = 'font-size'.encode(Encoding::US_ASCII).freeze
57
- PROP_LINE_HEIGHT = 'line-height'.encode(Encoding::US_ASCII).freeze
58
- PROP_FONT_FAMILY = 'font-family'.encode(Encoding::US_ASCII).freeze
59
-
60
- PROP_BACKGROUND = 'background'.encode(Encoding::US_ASCII).freeze
61
- PROP_BACKGROUND_COLOR = 'background-color'.encode(Encoding::US_ASCII).freeze
62
- PROP_BACKGROUND_IMAGE = 'background-image'.encode(Encoding::US_ASCII).freeze
63
- PROP_BACKGROUND_REPEAT = 'background-repeat'.encode(Encoding::US_ASCII).freeze
64
- PROP_BACKGROUND_ATTACHMENT = 'background-attachment'.encode(Encoding::US_ASCII).freeze
65
- PROP_BACKGROUND_POSITION = 'background-position'.encode(Encoding::US_ASCII).freeze
66
-
67
- PROP_LIST_STYLE = 'list-style'.encode(Encoding::US_ASCII).freeze
68
- PROP_LIST_STYLE_TYPE = 'list-style-type'.encode(Encoding::US_ASCII).freeze
69
- PROP_LIST_STYLE_POSITION = 'list-style-position'.encode(Encoding::US_ASCII).freeze
70
- PROP_LIST_STYLE_IMAGE = 'list-style-image'.encode(Encoding::US_ASCII).freeze
71
-
72
- # Shorthand property families
73
- MARGIN_SIDES = [PROP_MARGIN_TOP, PROP_MARGIN_RIGHT, PROP_MARGIN_BOTTOM, PROP_MARGIN_LEFT].freeze
74
- PADDING_SIDES = [PROP_PADDING_TOP, PROP_PADDING_RIGHT, PROP_PADDING_BOTTOM, PROP_PADDING_LEFT].freeze
75
-
76
- BORDER_WIDTHS = [
77
- PROP_BORDER_TOP_WIDTH,
78
- PROP_BORDER_RIGHT_WIDTH,
79
- PROP_BORDER_BOTTOM_WIDTH,
80
- PROP_BORDER_LEFT_WIDTH
81
- ].freeze
82
-
83
- BORDER_STYLES = [
84
- PROP_BORDER_TOP_STYLE,
85
- PROP_BORDER_RIGHT_STYLE,
86
- PROP_BORDER_BOTTOM_STYLE,
87
- PROP_BORDER_LEFT_STYLE
88
- ].freeze
89
-
90
- BORDER_COLORS = [
91
- PROP_BORDER_TOP_COLOR,
92
- PROP_BORDER_RIGHT_COLOR,
93
- PROP_BORDER_BOTTOM_COLOR,
94
- PROP_BORDER_LEFT_COLOR
95
- ].freeze
96
-
97
- # Side name constants (for string operations, not CSS properties)
98
- SIDE_TOP = 'top'
99
- SIDE_RIGHT = 'right'
100
- SIDE_BOTTOM = 'bottom'
101
- SIDE_LEFT = 'left'
102
-
103
- # Maps a border side name to its [width, style, color] property constants
104
- BORDER_SIDE_PROPS = {
105
- SIDE_TOP => [PROP_BORDER_TOP_WIDTH, PROP_BORDER_TOP_STYLE, PROP_BORDER_TOP_COLOR],
106
- SIDE_RIGHT => [PROP_BORDER_RIGHT_WIDTH, PROP_BORDER_RIGHT_STYLE, PROP_BORDER_RIGHT_COLOR],
107
- SIDE_BOTTOM => [PROP_BORDER_BOTTOM_WIDTH, PROP_BORDER_BOTTOM_STYLE, PROP_BORDER_BOTTOM_COLOR],
108
- SIDE_LEFT => [PROP_BORDER_LEFT_WIDTH, PROP_BORDER_LEFT_STYLE, PROP_BORDER_LEFT_COLOR]
109
- }.freeze
110
-
111
- FONT_PROPERTIES = [
112
- PROP_FONT_STYLE,
113
- PROP_FONT_VARIANT,
114
- PROP_FONT_WEIGHT,
115
- PROP_FONT_SIZE,
116
- PROP_LINE_HEIGHT,
117
- PROP_FONT_FAMILY
118
- ].freeze
119
-
120
- BACKGROUND_PROPERTIES = [
121
- PROP_BACKGROUND_COLOR,
122
- PROP_BACKGROUND_IMAGE,
123
- PROP_BACKGROUND_REPEAT,
124
- PROP_BACKGROUND_POSITION,
125
- PROP_BACKGROUND_ATTACHMENT
126
- ].freeze
127
-
128
- LIST_STYLE_PROPERTIES = [PROP_LIST_STYLE_TYPE, PROP_LIST_STYLE_POSITION, PROP_LIST_STYLE_IMAGE].freeze
129
- BORDER_ALL = (BORDER_WIDTHS + BORDER_STYLES + BORDER_COLORS).freeze
130
-
131
- # Shorthand property lookup (Hash is faster than Array#include? or Set)
132
- # Used for fast-path check to avoid calling expand_shorthand for non-shorthands
133
- SHORTHAND_PROPERTIES = {
134
- 'margin' => true,
135
- 'padding' => true,
136
- 'border' => true,
137
- 'border-top' => true,
138
- 'border-right' => true,
139
- 'border-bottom' => true,
140
- 'border-left' => true,
141
- 'border-width' => true,
142
- 'border-style' => true,
143
- 'border-color' => true,
144
- 'font' => true,
145
- 'background' => true,
146
- 'list-style' => true
147
- }.freeze
148
-
149
- # List style keywords
150
- LIST_STYLE_POSITION_KEYWORDS = %w[inside outside].freeze
151
-
152
- # Border property keywords
153
- BORDER_WIDTH_KEYWORDS = %w[thin medium thick].freeze
154
- BORDER_STYLE_KEYWORDS = %w[none hidden dotted dashed solid double groove ridge inset outset].freeze
155
-
156
- # Font property keywords
157
- FONT_SIZE_KEYWORDS = %w[xx-small x-small small medium large x-large xx-large smaller larger].freeze
158
- FONT_STYLE_KEYWORDS = %w[normal italic oblique].freeze
159
- FONT_VARIANT_KEYWORDS = %w[normal small-caps].freeze
160
- FONT_WEIGHT_KEYWORDS = %w[normal bold bolder lighter 100 200 300 400 500 600 700 800 900].freeze
161
-
162
- # Background property keywords
163
- BACKGROUND_REPEAT_KEYWORDS = %w[repeat repeat-x repeat-y no-repeat space round].freeze
164
- BACKGROUND_ATTACHMENT_KEYWORDS = %w[scroll fixed local].freeze
165
- BACKGROUND_POSITION_KEYWORDS = %w[left right center top bottom].freeze
166
-
167
- # Merge stylesheet according to CSS cascade rules
168
- #
169
- # @param stylesheet [Stylesheet] Stylesheet to merge
170
- # @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
171
- # @return [Stylesheet] Merged stylesheet
172
- def self.flatten(stylesheet, mutate: false)
173
- at_rules, regular_rules = partition_at_rules(stylesheet.rules)
174
-
175
- expand_all_shorthands!(regular_rules)
176
- merged_rules = merge_rules_by_selector_and_media(regular_rules)
177
- merged_rules.each { |rule| recreate_shorthands!(rule) }
178
-
179
- # Assign new IDs before checking divergence (so we can build correct selector_lists hash)
180
- merged_rules.each_with_index { |rule, i| rule.id = i }
181
-
182
- selector_lists = build_selector_lists(stylesheet, merged_rules)
183
-
184
- # Add passthrough AtRules to output
185
- merged_rules.concat(at_rules)
186
-
187
- media_queries = stylesheet.instance_variable_get(:@media_queries)
188
- media_query_lists = stylesheet.instance_variable_get(:@_media_query_lists)
189
- new_media_index = rebuild_media_index(merged_rules, media_queries, media_query_lists)
190
-
191
- build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists, selector_lists,
192
- mutate)
193
- end
194
-
195
- # Separate AtRules (pass-through, unaffected by cascade) from regular
196
- # Rules (to merge).
197
- #
198
- # @param rules [Array<Rule, AtRule>]
199
- # @return [Array(Array<AtRule>, Array<Rule>)]
200
- def self.partition_at_rules(rules)
201
- at_rules = []
202
- regular_rules = []
203
-
204
- rules.each do |rule|
205
- if rule.at_rule?
206
- at_rules << rule
207
- else
208
- regular_rules << rule
13
+ module Backends
14
+ class PureImpl
15
+ class FlattenImpl
16
+ # Property name constants (US-ASCII for merge output)
17
+ PROP_MARGIN = 'margin'.encode(Encoding::US_ASCII).freeze
18
+ PROP_MARGIN_TOP = 'margin-top'.encode(Encoding::US_ASCII).freeze
19
+ PROP_MARGIN_RIGHT = 'margin-right'.encode(Encoding::US_ASCII).freeze
20
+ PROP_MARGIN_BOTTOM = 'margin-bottom'.encode(Encoding::US_ASCII).freeze
21
+ PROP_MARGIN_LEFT = 'margin-left'.encode(Encoding::US_ASCII).freeze
22
+
23
+ PROP_PADDING = 'padding'.encode(Encoding::US_ASCII).freeze
24
+ PROP_PADDING_TOP = 'padding-top'.encode(Encoding::US_ASCII).freeze
25
+ PROP_PADDING_RIGHT = 'padding-right'.encode(Encoding::US_ASCII).freeze
26
+ PROP_PADDING_BOTTOM = 'padding-bottom'.encode(Encoding::US_ASCII).freeze
27
+ PROP_PADDING_LEFT = 'padding-left'.encode(Encoding::US_ASCII).freeze
28
+
29
+ PROP_BORDER = 'border'.encode(Encoding::US_ASCII).freeze
30
+ PROP_BORDER_WIDTH = 'border-width'.encode(Encoding::US_ASCII).freeze
31
+ PROP_BORDER_STYLE = 'border-style'.encode(Encoding::US_ASCII).freeze
32
+ PROP_BORDER_COLOR = 'border-color'.encode(Encoding::US_ASCII).freeze
33
+
34
+ PROP_BORDER_TOP = 'border-top'.encode(Encoding::US_ASCII).freeze
35
+ PROP_BORDER_RIGHT = 'border-right'.encode(Encoding::US_ASCII).freeze
36
+ PROP_BORDER_BOTTOM = 'border-bottom'.encode(Encoding::US_ASCII).freeze
37
+ PROP_BORDER_LEFT = 'border-left'.encode(Encoding::US_ASCII).freeze
38
+
39
+ PROP_BORDER_TOP_WIDTH = 'border-top-width'.encode(Encoding::US_ASCII).freeze
40
+ PROP_BORDER_RIGHT_WIDTH = 'border-right-width'.encode(Encoding::US_ASCII).freeze
41
+ PROP_BORDER_BOTTOM_WIDTH = 'border-bottom-width'.encode(Encoding::US_ASCII).freeze
42
+ PROP_BORDER_LEFT_WIDTH = 'border-left-width'.encode(Encoding::US_ASCII).freeze
43
+
44
+ PROP_BORDER_TOP_STYLE = 'border-top-style'.encode(Encoding::US_ASCII).freeze
45
+ PROP_BORDER_RIGHT_STYLE = 'border-right-style'.encode(Encoding::US_ASCII).freeze
46
+ PROP_BORDER_BOTTOM_STYLE = 'border-bottom-style'.encode(Encoding::US_ASCII).freeze
47
+ PROP_BORDER_LEFT_STYLE = 'border-left-style'.encode(Encoding::US_ASCII).freeze
48
+
49
+ PROP_BORDER_TOP_COLOR = 'border-top-color'.encode(Encoding::US_ASCII).freeze
50
+ PROP_BORDER_RIGHT_COLOR = 'border-right-color'.encode(Encoding::US_ASCII).freeze
51
+ PROP_BORDER_BOTTOM_COLOR = 'border-bottom-color'.encode(Encoding::US_ASCII).freeze
52
+ PROP_BORDER_LEFT_COLOR = 'border-left-color'.encode(Encoding::US_ASCII).freeze
53
+
54
+ PROP_FONT = 'font'.encode(Encoding::US_ASCII).freeze
55
+ PROP_FONT_STYLE = 'font-style'.encode(Encoding::US_ASCII).freeze
56
+ PROP_FONT_VARIANT = 'font-variant'.encode(Encoding::US_ASCII).freeze
57
+ PROP_FONT_WEIGHT = 'font-weight'.encode(Encoding::US_ASCII).freeze
58
+ PROP_FONT_SIZE = 'font-size'.encode(Encoding::US_ASCII).freeze
59
+ PROP_LINE_HEIGHT = 'line-height'.encode(Encoding::US_ASCII).freeze
60
+ PROP_FONT_FAMILY = 'font-family'.encode(Encoding::US_ASCII).freeze
61
+
62
+ PROP_BACKGROUND = 'background'.encode(Encoding::US_ASCII).freeze
63
+ PROP_BACKGROUND_COLOR = 'background-color'.encode(Encoding::US_ASCII).freeze
64
+ PROP_BACKGROUND_IMAGE = 'background-image'.encode(Encoding::US_ASCII).freeze
65
+ PROP_BACKGROUND_REPEAT = 'background-repeat'.encode(Encoding::US_ASCII).freeze
66
+ PROP_BACKGROUND_ATTACHMENT = 'background-attachment'.encode(Encoding::US_ASCII).freeze
67
+ PROP_BACKGROUND_POSITION = 'background-position'.encode(Encoding::US_ASCII).freeze
68
+
69
+ PROP_LIST_STYLE = 'list-style'.encode(Encoding::US_ASCII).freeze
70
+ PROP_LIST_STYLE_TYPE = 'list-style-type'.encode(Encoding::US_ASCII).freeze
71
+ PROP_LIST_STYLE_POSITION = 'list-style-position'.encode(Encoding::US_ASCII).freeze
72
+ PROP_LIST_STYLE_IMAGE = 'list-style-image'.encode(Encoding::US_ASCII).freeze
73
+
74
+ # Shorthand property families
75
+ MARGIN_SIDES = [PROP_MARGIN_TOP, PROP_MARGIN_RIGHT, PROP_MARGIN_BOTTOM, PROP_MARGIN_LEFT].freeze
76
+ PADDING_SIDES = [PROP_PADDING_TOP, PROP_PADDING_RIGHT, PROP_PADDING_BOTTOM, PROP_PADDING_LEFT].freeze
77
+
78
+ BORDER_WIDTHS = [
79
+ PROP_BORDER_TOP_WIDTH,
80
+ PROP_BORDER_RIGHT_WIDTH,
81
+ PROP_BORDER_BOTTOM_WIDTH,
82
+ PROP_BORDER_LEFT_WIDTH
83
+ ].freeze
84
+
85
+ BORDER_STYLES = [
86
+ PROP_BORDER_TOP_STYLE,
87
+ PROP_BORDER_RIGHT_STYLE,
88
+ PROP_BORDER_BOTTOM_STYLE,
89
+ PROP_BORDER_LEFT_STYLE
90
+ ].freeze
91
+
92
+ BORDER_COLORS = [
93
+ PROP_BORDER_TOP_COLOR,
94
+ PROP_BORDER_RIGHT_COLOR,
95
+ PROP_BORDER_BOTTOM_COLOR,
96
+ PROP_BORDER_LEFT_COLOR
97
+ ].freeze
98
+
99
+ # Side name constants (for string operations, not CSS properties)
100
+ SIDE_TOP = 'top'
101
+ SIDE_RIGHT = 'right'
102
+ SIDE_BOTTOM = 'bottom'
103
+ SIDE_LEFT = 'left'
104
+
105
+ # Maps a border side name to its [width, style, color] property constants
106
+ BORDER_SIDE_PROPS = {
107
+ SIDE_TOP => [PROP_BORDER_TOP_WIDTH, PROP_BORDER_TOP_STYLE, PROP_BORDER_TOP_COLOR],
108
+ SIDE_RIGHT => [PROP_BORDER_RIGHT_WIDTH, PROP_BORDER_RIGHT_STYLE, PROP_BORDER_RIGHT_COLOR],
109
+ SIDE_BOTTOM => [PROP_BORDER_BOTTOM_WIDTH, PROP_BORDER_BOTTOM_STYLE, PROP_BORDER_BOTTOM_COLOR],
110
+ SIDE_LEFT => [PROP_BORDER_LEFT_WIDTH, PROP_BORDER_LEFT_STYLE, PROP_BORDER_LEFT_COLOR]
111
+ }.freeze
112
+
113
+ FONT_PROPERTIES = [
114
+ PROP_FONT_STYLE,
115
+ PROP_FONT_VARIANT,
116
+ PROP_FONT_WEIGHT,
117
+ PROP_FONT_SIZE,
118
+ PROP_LINE_HEIGHT,
119
+ PROP_FONT_FAMILY
120
+ ].freeze
121
+
122
+ BACKGROUND_PROPERTIES = [
123
+ PROP_BACKGROUND_COLOR,
124
+ PROP_BACKGROUND_IMAGE,
125
+ PROP_BACKGROUND_REPEAT,
126
+ PROP_BACKGROUND_POSITION,
127
+ PROP_BACKGROUND_ATTACHMENT
128
+ ].freeze
129
+
130
+ LIST_STYLE_PROPERTIES = [PROP_LIST_STYLE_TYPE, PROP_LIST_STYLE_POSITION, PROP_LIST_STYLE_IMAGE].freeze
131
+ BORDER_ALL = (BORDER_WIDTHS + BORDER_STYLES + BORDER_COLORS).freeze
132
+
133
+ # Shorthand property lookup (Hash is faster than Array#include? or Set)
134
+ # Used for fast-path check to avoid calling expand_shorthand for non-shorthands
135
+ SHORTHAND_PROPERTIES = {
136
+ 'margin' => true,
137
+ 'padding' => true,
138
+ 'border' => true,
139
+ 'border-top' => true,
140
+ 'border-right' => true,
141
+ 'border-bottom' => true,
142
+ 'border-left' => true,
143
+ 'border-width' => true,
144
+ 'border-style' => true,
145
+ 'border-color' => true,
146
+ 'font' => true,
147
+ 'background' => true,
148
+ 'list-style' => true
149
+ }.freeze
150
+
151
+ # List style keywords
152
+ LIST_STYLE_POSITION_KEYWORDS = %w[inside outside].freeze
153
+
154
+ # Border property keywords
155
+ BORDER_WIDTH_KEYWORDS = %w[thin medium thick].freeze
156
+ BORDER_STYLE_KEYWORDS = %w[none hidden dotted dashed solid double groove ridge inset outset].freeze
157
+
158
+ # Font property keywords
159
+ FONT_SIZE_KEYWORDS = %w[xx-small x-small small medium large x-large xx-large smaller larger].freeze
160
+ FONT_STYLE_KEYWORDS = %w[normal italic oblique].freeze
161
+ FONT_VARIANT_KEYWORDS = %w[normal small-caps].freeze
162
+ FONT_WEIGHT_KEYWORDS = %w[normal bold bolder lighter 100 200 300 400 500 600 700 800 900].freeze
163
+
164
+ # Background property keywords
165
+ BACKGROUND_REPEAT_KEYWORDS = %w[repeat repeat-x repeat-y no-repeat space round].freeze
166
+ BACKGROUND_ATTACHMENT_KEYWORDS = %w[scroll fixed local].freeze
167
+ BACKGROUND_POSITION_KEYWORDS = %w[left right center top bottom].freeze
168
+
169
+ # Merge stylesheet according to CSS cascade rules
170
+ #
171
+ # @param stylesheet [Stylesheet] Stylesheet to merge
172
+ # @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
173
+ # @return [Stylesheet] Merged stylesheet
174
+ def flatten(stylesheet, mutate: false)
175
+ at_rules, regular_rules = partition_at_rules(stylesheet.rules)
176
+
177
+ expand_all_shorthands!(regular_rules)
178
+ merged_rules = merge_rules_by_selector_and_media(regular_rules)
179
+ merged_rules.each { |rule| recreate_shorthands!(rule) }
180
+
181
+ # Assign new IDs before checking divergence (so we can build correct selector_lists hash)
182
+ merged_rules.each_with_index { |rule, i| rule.id = i }
183
+
184
+ selector_lists = build_selector_lists(stylesheet, merged_rules)
185
+
186
+ # Add passthrough AtRules to output
187
+ merged_rules.concat(at_rules)
188
+
189
+ media_queries = stylesheet.media_queries
190
+ media_query_lists = stylesheet.instance_variable_get(:@_media_query_lists)
191
+ new_media_index = rebuild_media_index(merged_rules, media_queries, media_query_lists)
192
+
193
+ build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists, selector_lists,
194
+ mutate)
209
195
  end
210
- end
211
196
 
212
- [at_rules, regular_rules]
213
- end
197
+ # Separate AtRules (pass-through, unaffected by cascade) from regular
198
+ # Rules (to merge). Regular rules are duped (including their
199
+ # declarations array) since expand_all_shorthands! mutates
200
+ # declarations in place - without this, the caller's stylesheet
201
+ # would get its shorthand properties silently expanded even when
202
+ # flatten (not flatten!) was called.
203
+ #
204
+ # @param rules [Array<Rule, AtRule>]
205
+ # @return [Array(Array<AtRule>, Array<Rule>)]
206
+ def partition_at_rules(rules)
207
+ at_rules = []
208
+ regular_rules = []
209
+
210
+ rules.each do |rule|
211
+ if rule.at_rule?
212
+ at_rules << rule
213
+ else
214
+ copy = rule.dup
215
+ copy.declarations = rule.declarations.dup
216
+ regular_rules << copy
217
+ end
218
+ end
214
219
 
215
- # Expand shorthands in regular rules only (AtRules don't have declarations).
216
- # Mutates each rule's declarations in place.
217
- #
218
- # NOTE: Using manual each + concat instead of .flat_map for performance.
219
- # The concise form (.flat_map) is ~5-10% slower depending on number of shorthands to expand.
220
- # NOTE: Fast-path check for shorthands (Hash lookup) avoids calling expand_shorthand
221
- # for declarations that are not shorthands (~20% faster than calling method unconditionally).
222
- #
223
- # @param regular_rules [Array<Rule>]
224
- # @return [void]
225
- def self.expand_all_shorthands!(regular_rules)
226
- regular_rules.each do |rule|
227
- expanded = []
228
- rule.declarations.each do |decl|
229
- if SHORTHAND_PROPERTIES[decl.property]
230
- expanded.concat(expand_shorthand(decl))
231
- else
232
- expanded << decl
220
+ [at_rules, regular_rules]
221
+ end
222
+
223
+ # Expand shorthands in regular rules only (AtRules don't have declarations).
224
+ # Mutates each rule's declarations in place.
225
+ #
226
+ # NOTE: Using manual each + concat instead of .flat_map for performance.
227
+ # The concise form (.flat_map) is ~5-10% slower depending on number of shorthands to expand.
228
+ # NOTE: Fast-path check for shorthands (Hash lookup) avoids calling expand_shorthand
229
+ # for declarations that are not shorthands (~20% faster than calling method unconditionally).
230
+ #
231
+ # @param regular_rules [Array<Rule>]
232
+ # @return [void]
233
+ def expand_all_shorthands!(regular_rules)
234
+ regular_rules.each do |rule|
235
+ expanded = []
236
+ rule.declarations.each do |decl|
237
+ if SHORTHAND_PROPERTIES[decl.property]
238
+ expanded.concat(expand_shorthand(decl))
239
+ else
240
+ expanded << decl
241
+ end
242
+ end
243
+ rule.declarations.replace(expanded)
233
244
  end
234
245
  end
235
- rule.declarations.replace(expanded)
236
- end
237
- end
238
246
 
239
- # Group rules by (selector, media_query_id) and merge each group
240
- # according to CSS cascade rules. Rules with the same selector but
241
- # different media contexts are NOT merged together.
242
- #
243
- # @param regular_rules [Array<Rule>]
244
- # @return [Array<Rule>] Merged rules, one per (selector, media_query_id) group
245
- def self.merge_rules_by_selector_and_media(regular_rules)
246
- # NOTE: Using manual each instead of .group_by to avoid intermediate hash allocation.
247
- by_selector_and_media = {}
248
- regular_rules.each do |rule|
249
- key = [rule.selector, rule.media_query_id]
250
- (by_selector_and_media[key] ||= []) << rule
251
- end
247
+ # Group rules by (selector, media_query_id) and merge each group
248
+ # according to CSS cascade rules. Rules with the same selector but
249
+ # different media contexts are NOT merged together.
250
+ #
251
+ # @param regular_rules [Array<Rule>]
252
+ # @return [Array<Rule>] Merged rules, one per (selector, media_query_id) group
253
+ def merge_rules_by_selector_and_media(regular_rules)
254
+ # NOTE: Using manual each instead of .group_by to avoid intermediate hash allocation.
255
+ by_selector_and_media = {}
256
+ regular_rules.each do |rule|
257
+ key = [rule.selector, rule.media_query_id]
258
+ (by_selector_and_media[key] ||= []) << rule
259
+ end
252
260
 
253
- merged_rules = []
254
- by_selector_and_media.each_value do |rules|
255
- merged_rule = flatten_rules_for_selector(rules.first.selector, rules)
256
- merged_rules << merged_rule if merged_rule
257
- end
258
- merged_rules
259
- end
261
+ merged_rules = []
262
+ by_selector_and_media.each_value do |rules|
263
+ merged_rule = flatten_rules_for_selector(rules.first.selector, rules)
264
+ merged_rules << merged_rule if merged_rule
265
+ end
266
+ merged_rules
267
+ end
260
268
 
261
- # Handle selector list divergence: remove rules from selector lists if
262
- # declarations no longer match. This makes selector_list_id
263
- # authoritative - if set, declarations MUST be identical. Only
264
- # processed if selector_lists is enabled in the stylesheet's parser
265
- # options.
266
- #
267
- # @param stylesheet [Stylesheet] Source stylesheet (for parser options)
268
- # @param merged_rules [Array<Rule>] Merged rules, already assigned final IDs
269
- # @return [Hash] list_id => Array of rule IDs, empty if not tracking selector lists
270
- def self.build_selector_lists(stylesheet, merged_rules)
271
- selector_lists = {}
272
- parser_options = stylesheet.instance_variable_get(:@parser_options) || {}
273
- update_selector_lists_for_divergence!(merged_rules, selector_lists) if parser_options[:selector_lists]
274
- selector_lists
275
- end
269
+ # Handle selector list divergence: remove rules from selector lists if
270
+ # declarations no longer match. This makes selector_list_id
271
+ # authoritative - if set, declarations MUST be identical. Only
272
+ # processed if selector_lists is enabled in the stylesheet's parser
273
+ # options.
274
+ #
275
+ # @param stylesheet [Stylesheet] Source stylesheet (for parser options)
276
+ # @param merged_rules [Array<Rule>] Merged rules, already assigned final IDs
277
+ # @return [Hash] list_id => Array of rule IDs, empty if not tracking selector lists
278
+ def build_selector_lists(stylesheet, merged_rules)
279
+ selector_lists = {}
280
+ parser_options = stylesheet.instance_variable_get(:@parser_options) || {}
281
+ update_selector_lists_for_divergence!(merged_rules, selector_lists) if parser_options[:selector_lists]
282
+ selector_lists
283
+ end
276
284
 
277
- # Rebuild media_index from rules' media_query_id. This ensures
278
- # media_index is consistent with the MediaQuery objects, rather than
279
- # trying to remap the stylesheet's existing (now-stale) media_index.
280
- #
281
- # @param merged_rules [Array<Rule, AtRule>] Final rules, including passthrough AtRules
282
- # @param media_queries [Array<MediaQuery>]
283
- # @param media_query_lists [Hash{Integer => Array<Integer>}] list_id => media_query_ids
284
- # @return [Hash{Symbol => Array<Integer>}] media type => rule IDs
285
- def self.rebuild_media_index(merged_rules, media_queries, media_query_lists)
286
- new_media_index = {}
287
-
288
- # Build reverse map: media_query_id => list_id (one-time cost)
289
- mq_id_to_list_id = {}
290
- media_query_lists.each do |list_id, mq_ids|
291
- mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
292
- end
285
+ # Rebuild media_index from rules' media_query_id. This ensures
286
+ # media_index is consistent with the MediaQuery objects, rather than
287
+ # trying to remap the stylesheet's existing (now-stale) media_index.
288
+ #
289
+ # @param merged_rules [Array<Rule, AtRule>] Final rules, including passthrough AtRules
290
+ # @param media_queries [Array<MediaQuery>]
291
+ # @param media_query_lists [Hash{Integer => Array<Integer>}] list_id => media_query_ids
292
+ # @return [Hash{Symbol => Array<Integer>}] media type => rule IDs
293
+ def rebuild_media_index(merged_rules, media_queries, media_query_lists)
294
+ new_media_index = {}
295
+
296
+ # Build reverse map: media_query_id => list_id (one-time cost)
297
+ mq_id_to_list_id = {}
298
+ media_query_lists.each do |list_id, mq_ids|
299
+ mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
300
+ end
301
+
302
+ merged_rules.each do |rule|
303
+ next unless rule.is_a?(Rule) && rule.media_query_id
293
304
 
294
- merged_rules.each do |rule|
295
- next unless rule.is_a?(Rule) && rule.media_query_id
305
+ # Check if this rule's media_query_id is part of a list
306
+ list_id = mq_id_to_list_id[rule.media_query_id]
296
307
 
297
- # Check if this rule's media_query_id is part of a list
298
- list_id = mq_id_to_list_id[rule.media_query_id]
308
+ if list_id
309
+ # This rule is in a compound media query (e.g., "@media screen, print")
310
+ # Index it under ALL media types in the list
311
+ mq_ids = media_query_lists[list_id]
312
+ mq_ids.each do |mq_id|
313
+ mq = media_queries[mq_id]
314
+ next unless mq
299
315
 
300
- if list_id
301
- # This rule is in a compound media query (e.g., "@media screen, print")
302
- # Index it under ALL media types in the list
303
- mq_ids = media_query_lists[list_id]
304
- mq_ids.each do |mq_id|
305
- mq = media_queries[mq_id]
306
- next unless mq
316
+ new_media_index[mq.type] ||= []
317
+ new_media_index[mq.type] << rule.id
318
+ end
319
+ else
320
+ # Single media query - just index under its type
321
+ mq = media_queries[rule.media_query_id]
322
+ next unless mq
307
323
 
308
- new_media_index[mq.type] ||= []
309
- new_media_index[mq.type] << rule.id
324
+ new_media_index[mq.type] ||= []
325
+ new_media_index[mq.type] << rule.id
326
+ end
310
327
  end
311
- else
312
- # Single media query - just index under its type
313
- mq = media_queries[rule.media_query_id]
314
- next unless mq
315
328
 
316
- new_media_index[mq.type] ||= []
317
- new_media_index[mq.type] << rule.id
329
+ # Deduplicate arrays once at the end
330
+ new_media_index.each_value(&:uniq!)
331
+ new_media_index
318
332
  end
319
- end
320
333
 
321
- # Deduplicate arrays once at the end
322
- new_media_index.each_value(&:uniq!)
323
- new_media_index
324
- end
334
+ # Build the final result: either mutate the input stylesheet in place,
335
+ # or construct a new one, with the merged rules/media_index/selector_lists.
336
+ #
337
+ # @return [Stylesheet]
338
+ def build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists,
339
+ selector_lists, mutate)
340
+ if mutate
341
+ stylesheet.instance_variable_set(:@rules, merged_rules)
342
+ stylesheet.instance_variable_set(:@media_index, new_media_index)
343
+ # @media_queries and @_media_query_lists stay the same - preserved from input
344
+ stylesheet.instance_variable_set(:@_selector_lists, selector_lists)
345
+ stylesheet
346
+ else
347
+ result = Stylesheet.new
348
+ result.instance_variable_set(:@rules, merged_rules)
349
+ result.instance_variable_set(:@media_index, new_media_index)
350
+ result.instance_variable_set(:@media_queries, media_queries)
351
+ result.instance_variable_set(:@_media_query_lists, media_query_lists)
352
+ result.instance_variable_set(:@charset, stylesheet.charset)
353
+ result.instance_variable_set(:@_selector_lists, selector_lists)
354
+ result
355
+ end
356
+ end
325
357
 
326
- # Build the final result: either mutate the input stylesheet in place,
327
- # or construct a new one, with the merged rules/media_index/selector_lists.
328
- #
329
- # @return [Stylesheet]
330
- def self.build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists,
331
- selector_lists, mutate)
332
- if mutate
333
- stylesheet.instance_variable_set(:@rules, merged_rules)
334
- stylesheet.instance_variable_set(:@media_index, new_media_index)
335
- # @media_queries and @_media_query_lists stay the same - preserved from input
336
- stylesheet.instance_variable_set(:@_selector_lists, selector_lists)
337
- stylesheet
338
- else
339
- result = Stylesheet.new
340
- result.instance_variable_set(:@rules, merged_rules)
341
- result.instance_variable_set(:@media_index, new_media_index)
342
- result.instance_variable_set(:@media_queries, media_queries)
343
- result.instance_variable_set(:@_media_query_lists, media_query_lists)
344
- result.instance_variable_set(:@charset, stylesheet.charset)
345
- result.instance_variable_set(:@_selector_lists, selector_lists)
346
- result
347
- end
348
- end
358
+ # Merge multiple rules with same selector
359
+ #
360
+ # @param selector [String] The selector
361
+ # @param rules [Array<Rule>] Rules with this selector
362
+ # @return [Rule] Merged rule with cascaded declarations
363
+ def flatten_rules_for_selector(selector, rules)
364
+ # Build declaration map: property => [source_order, specificity, important, value]
365
+ decl_map = {}
366
+
367
+ rules.each do |rule|
368
+ spec = rule.specificity || calculate_specificity(rule.selector)
369
+
370
+ rule.declarations.each_with_index do |decl, idx|
371
+ # Property is already US-ASCII and lowercase from parser
372
+ prop = decl.property
373
+
374
+ # Calculate source order (higher = later)
375
+ source_order = rule.id * 1000 + idx
376
+
377
+ existing = decl_map[prop]
378
+
379
+ # Apply cascade rules:
380
+ # 1. !important always wins over non-important
381
+ # 2. Higher specificity wins
382
+ # 3. Later source order wins (if specificity and importance are equal)
383
+
384
+ if existing.nil?
385
+ decl_map[prop] = [source_order, spec, decl.important, decl.value]
386
+ else
387
+ existing_order, existing_spec, existing_important, _existing_val = existing
388
+
389
+ # Determine winner
390
+ should_replace = false
391
+
392
+ if decl.important && !existing_important
393
+ # New is important, existing is not -> new wins
394
+ should_replace = true
395
+ elsif !decl.important && existing_important
396
+ # Existing is important, new is not -> existing wins
397
+ should_replace = false
398
+ elsif spec > existing_spec
399
+ # Higher specificity wins
400
+ should_replace = true
401
+ elsif spec < existing_spec
402
+ # Lower specificity loses
403
+ should_replace = false
404
+ else
405
+ # Same specificity and importance -> later source order wins
406
+ should_replace = source_order > existing_order
407
+ end
408
+
409
+ if should_replace
410
+ decl_map[prop] = [source_order, spec, decl.important, decl.value]
411
+ end
412
+ end
413
+ end
414
+ end
349
415
 
350
- # Merge multiple rules with same selector
351
- #
352
- # @param selector [String] The selector
353
- # @param rules [Array<Rule>] Rules with this selector
354
- # @return [Rule] Merged rule with cascaded declarations
355
- def self.flatten_rules_for_selector(selector, rules)
356
- # Build declaration map: property => [source_order, specificity, important, value]
357
- decl_map = {}
416
+ # Build final declarations array
417
+ # NOTE: Using each with << instead of map for performance (1.05-1.11x faster)
418
+ # The << pattern is faster than map's implicit array return (even without YJIT)
419
+ #
420
+ # NOTE: We don't sort by source_order here because:
421
+ # 1. Hash iteration order in Ruby is insertion order (since Ruby 1.9)
422
+ # 2. Declaration order doesn't affect CSS behavior (cascade is already resolved)
423
+ # 3. Sorting would add overhead for purely aesthetic output
424
+ # The output order is roughly source order but may vary when properties are
425
+ # overridden by later rules with higher specificity or importance.
426
+ declarations = []
427
+ decl_map.each do |prop, (_order, _spec, important, value)|
428
+ declarations << Declaration.new(prop, value, important)
429
+ end
358
430
 
359
- rules.each do |rule|
360
- spec = rule.specificity || calculate_specificity(rule.selector)
431
+ return nil if declarations.empty?
432
+
433
+ # Preserve selector_list_id if all rules in group share the same one
434
+ selector_list_ids = rules.filter_map(&:selector_list_id)
435
+ selector_list_ids.uniq!
436
+ selector_list_id = selector_list_ids.size == 1 ? selector_list_ids.first : nil
437
+
438
+ # All rules being merged have the same media_query_id (they were grouped by it)
439
+ media_query_id = rules.first.media_query_id
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
+
446
+ # Create merged rule
447
+ Rule.new(
448
+ 0, # ID will be updated later
449
+ selector,
450
+ declarations,
451
+ rules.first.specificity, # Use first rule's specificity
452
+ nil, # No parent after flattening
453
+ nil, # No nesting style after flattening
454
+ selector_list_id, # Preserve if all rules share same ID
455
+ media_query_id, # Preserve media context
456
+ conditional_group_id # Preserve conditional-group context
457
+ )
458
+ end
361
459
 
362
- rule.declarations.each_with_index do |decl, idx|
363
- # Property is already US-ASCII and lowercase from parser
364
- prop = decl.property
460
+ # Calculate specificity for a selector
461
+ #
462
+ # @param selector [String] CSS selector
463
+ # @return [Integer] Specificity value
464
+ def calculate_specificity(selector)
465
+ Pure.calculate_specificity(selector)
466
+ end
365
467
 
366
- # Calculate source order (higher = later)
367
- source_order = rule.id * 1000 + idx
468
+ # Expand a single shorthand declaration into longhand declarations.
469
+ # Returns an array of longhand declarations. If the declaration is not a shorthand,
470
+ # returns an array with just that declaration.
471
+ #
472
+ # @param decl [Declaration] Declaration to expand
473
+ # @return [Array<Declaration>] Array of expanded longhand declarations
474
+ # @api private
475
+ def expand_shorthand(decl)
476
+ case decl.property
477
+ when 'margin'
478
+ expand_margin(decl)
479
+ when 'padding'
480
+ expand_padding(decl)
481
+ when 'border'
482
+ expand_border(decl)
483
+ when 'border-top', 'border-right', 'border-bottom', 'border-left'
484
+ expand_border_side(decl)
485
+ when 'border-width'
486
+ expand_border_width(decl)
487
+ when 'border-style'
488
+ expand_border_style(decl)
489
+ when 'border-color'
490
+ expand_border_color(decl)
491
+ when 'font'
492
+ expand_font(decl)
493
+ when 'background'
494
+ expand_background(decl)
495
+ when 'list-style'
496
+ expand_list_style(decl)
497
+ else
498
+ # Not a shorthand, return as-is in an array
499
+ [decl]
500
+ end
501
+ end
368
502
 
369
- existing = decl_map[prop]
503
+ # Expand margin shorthand
504
+ def expand_margin(decl)
505
+ sides = parse_four_sides(decl.value)
506
+ [
507
+ Declaration.new(PROP_MARGIN_TOP, sides[0], decl.important),
508
+ Declaration.new(PROP_MARGIN_RIGHT, sides[1], decl.important),
509
+ Declaration.new(PROP_MARGIN_BOTTOM, sides[2], decl.important),
510
+ Declaration.new(PROP_MARGIN_LEFT, sides[3], decl.important)
511
+ ]
512
+ end
370
513
 
371
- # Apply cascade rules:
372
- # 1. !important always wins over non-important
373
- # 2. Higher specificity wins
374
- # 3. Later source order wins (if specificity and importance are equal)
514
+ # Expand padding shorthand
515
+ def expand_padding(decl)
516
+ sides = parse_four_sides(decl.value)
517
+ [
518
+ Declaration.new(PROP_PADDING_TOP, sides[0], decl.important),
519
+ Declaration.new(PROP_PADDING_RIGHT, sides[1], decl.important),
520
+ Declaration.new(PROP_PADDING_BOTTOM, sides[2], decl.important),
521
+ Declaration.new(PROP_PADDING_LEFT, sides[3], decl.important)
522
+ ]
523
+ end
375
524
 
376
- if existing.nil?
377
- decl_map[prop] = [source_order, spec, decl.important, decl.value]
525
+ # Parse four-sided value (margin/padding)
526
+ # "10px" -> ["10px", "10px", "10px", "10px"]
527
+ # "10px 20px" -> ["10px", "20px", "10px", "20px"]
528
+ # "10px 20px 30px" -> ["10px", "20px", "30px", "20px"]
529
+ # "10px 20px 30px 40px" -> ["10px", "20px", "30px", "40px"]
530
+ def parse_four_sides(value)
531
+ parts = split_on_whitespace(value)
532
+
533
+ case parts.length
534
+ when 1
535
+ [parts[0], parts[0], parts[0], parts[0]]
536
+ when 2
537
+ [parts[0], parts[1], parts[0], parts[1]]
538
+ when 3
539
+ [parts[0], parts[1], parts[2], parts[1]]
378
540
  else
379
- existing_order, existing_spec, existing_important, _existing_val = existing
380
-
381
- # Determine winner
382
- should_replace = false
383
-
384
- if decl.important && !existing_important
385
- # New is important, existing is not -> new wins
386
- should_replace = true
387
- elsif !decl.important && existing_important
388
- # Existing is important, new is not -> existing wins
389
- should_replace = false
390
- elsif spec > existing_spec
391
- # Higher specificity wins
392
- should_replace = true
393
- elsif spec < existing_spec
394
- # Lower specificity loses
395
- should_replace = false
541
+ [parts[0], parts[1], parts[2], parts[3]]
542
+ end
543
+ end
544
+
545
+ # Split value on whitespace (handling calc() and other functions)
546
+ def split_on_whitespace(value)
547
+ parts = []
548
+ current = String.new
549
+ paren_depth = 0
550
+
551
+ i = 0
552
+ len = value.bytesize
553
+ while i < len
554
+ byte = value.getbyte(i)
555
+
556
+ if byte == BYTE_LPAREN
557
+ paren_depth += 1
558
+ current << byte
559
+ elsif byte == BYTE_RPAREN
560
+ paren_depth -= 1
561
+ current << byte
562
+ elsif byte == BYTE_SPACE && paren_depth == 0
563
+ parts << current unless current.empty?
564
+ current = String.new
396
565
  else
397
- # Same specificity and importance -> later source order wins
398
- should_replace = source_order > existing_order
566
+ current << byte
399
567
  end
400
568
 
401
- if should_replace
402
- decl_map[prop] = [source_order, spec, decl.important, decl.value]
403
- end
569
+ i += 1
404
570
  end
571
+
572
+ parts << current unless current.empty?
573
+ parts
405
574
  end
406
- end
407
575
 
408
- # Build final declarations array
409
- # NOTE: Using each with << instead of map for performance (1.05-1.11x faster)
410
- # The << pattern is faster than map's implicit array return (even without YJIT)
411
- #
412
- # NOTE: We don't sort by source_order here because:
413
- # 1. Hash iteration order in Ruby is insertion order (since Ruby 1.9)
414
- # 2. Declaration order doesn't affect CSS behavior (cascade is already resolved)
415
- # 3. Sorting would add overhead for purely aesthetic output
416
- # The output order is roughly source order but may vary when properties are
417
- # overridden by later rules with higher specificity or importance.
418
- declarations = []
419
- decl_map.each do |prop, (_order, _spec, important, value)|
420
- declarations << Declaration.new(prop, value, important)
421
- end
576
+ # Expand border shorthand (e.g., "1px solid red")
577
+ def expand_border(decl)
578
+ # Parse border value
579
+ width, style, color = parse_border_value(decl.value)
422
580
 
423
- return nil if declarations.empty?
424
-
425
- # Preserve selector_list_id if all rules in group share the same one
426
- selector_list_ids = rules.filter_map(&:selector_list_id)
427
- selector_list_ids.uniq!
428
- selector_list_id = selector_list_ids.size == 1 ? selector_list_ids.first : nil
429
-
430
- # All rules being merged have the same media_query_id (they were grouped by it)
431
- media_query_id = rules.first.media_query_id
432
-
433
- # Create merged rule
434
- Rule.new(
435
- 0, # ID will be updated later
436
- selector,
437
- declarations,
438
- rules.first.specificity, # Use first rule's specificity
439
- nil, # No parent after flattening
440
- nil, # No nesting style after flattening
441
- selector_list_id, # Preserve if all rules share same ID
442
- media_query_id # Preserve media context
443
- )
444
- end
581
+ result = []
445
582
 
446
- # Calculate specificity for a selector
447
- #
448
- # @param selector [String] CSS selector
449
- # @return [Integer] Specificity value
450
- def self.calculate_specificity(selector)
451
- Cataract.calculate_specificity(selector)
452
- end
583
+ # Expand to all sides using property constants
584
+ if width
585
+ result << Declaration.new(PROP_BORDER_TOP_WIDTH, width, decl.important)
586
+ result << Declaration.new(PROP_BORDER_RIGHT_WIDTH, width, decl.important)
587
+ result << Declaration.new(PROP_BORDER_BOTTOM_WIDTH, width, decl.important)
588
+ result << Declaration.new(PROP_BORDER_LEFT_WIDTH, width, decl.important)
589
+ end
453
590
 
454
- # Expand a single shorthand declaration into longhand declarations.
455
- # Returns an array of longhand declarations. If the declaration is not a shorthand,
456
- # returns an array with just that declaration.
457
- #
458
- # @param decl [Declaration] Declaration to expand
459
- # @return [Array<Declaration>] Array of expanded longhand declarations
460
- # @api private
461
- def self.expand_shorthand(decl)
462
- case decl.property
463
- when 'margin'
464
- expand_margin(decl)
465
- when 'padding'
466
- expand_padding(decl)
467
- when 'border'
468
- expand_border(decl)
469
- when 'border-top', 'border-right', 'border-bottom', 'border-left'
470
- expand_border_side(decl)
471
- when 'border-width'
472
- expand_border_width(decl)
473
- when 'border-style'
474
- expand_border_style(decl)
475
- when 'border-color'
476
- expand_border_color(decl)
477
- when 'font'
478
- expand_font(decl)
479
- when 'background'
480
- expand_background(decl)
481
- when 'list-style'
482
- expand_list_style(decl)
483
- else
484
- # Not a shorthand, return as-is in an array
485
- [decl]
486
- end
487
- end
591
+ if style
592
+ result << Declaration.new(PROP_BORDER_TOP_STYLE, style, decl.important)
593
+ result << Declaration.new(PROP_BORDER_RIGHT_STYLE, style, decl.important)
594
+ result << Declaration.new(PROP_BORDER_BOTTOM_STYLE, style, decl.important)
595
+ result << Declaration.new(PROP_BORDER_LEFT_STYLE, style, decl.important)
596
+ end
488
597
 
489
- # Expand margin shorthand
490
- def self.expand_margin(decl)
491
- sides = parse_four_sides(decl.value)
492
- [
493
- Declaration.new(PROP_MARGIN_TOP, sides[0], decl.important),
494
- Declaration.new(PROP_MARGIN_RIGHT, sides[1], decl.important),
495
- Declaration.new(PROP_MARGIN_BOTTOM, sides[2], decl.important),
496
- Declaration.new(PROP_MARGIN_LEFT, sides[3], decl.important)
497
- ]
498
- end
598
+ if color
599
+ result << Declaration.new(PROP_BORDER_TOP_COLOR, color, decl.important)
600
+ result << Declaration.new(PROP_BORDER_RIGHT_COLOR, color, decl.important)
601
+ result << Declaration.new(PROP_BORDER_BOTTOM_COLOR, color, decl.important)
602
+ result << Declaration.new(PROP_BORDER_LEFT_COLOR, color, decl.important)
603
+ end
499
604
 
500
- # Expand padding shorthand
501
- def self.expand_padding(decl)
502
- sides = parse_four_sides(decl.value)
503
- [
504
- Declaration.new(PROP_PADDING_TOP, sides[0], decl.important),
505
- Declaration.new(PROP_PADDING_RIGHT, sides[1], decl.important),
506
- Declaration.new(PROP_PADDING_BOTTOM, sides[2], decl.important),
507
- Declaration.new(PROP_PADDING_LEFT, sides[3], decl.important)
508
- ]
509
- end
605
+ result
606
+ end
510
607
 
511
- # Parse four-sided value (margin/padding)
512
- # "10px" -> ["10px", "10px", "10px", "10px"]
513
- # "10px 20px" -> ["10px", "20px", "10px", "20px"]
514
- # "10px 20px 30px" -> ["10px", "20px", "30px", "20px"]
515
- # "10px 20px 30px 40px" -> ["10px", "20px", "30px", "40px"]
516
- def self.parse_four_sides(value)
517
- parts = split_on_whitespace(value)
518
-
519
- case parts.length
520
- when 1
521
- [parts[0], parts[0], parts[0], parts[0]]
522
- when 2
523
- [parts[0], parts[1], parts[0], parts[1]]
524
- when 3
525
- [parts[0], parts[1], parts[2], parts[1]]
526
- else
527
- [parts[0], parts[1], parts[2], parts[3]]
528
- end
529
- end
608
+ # Expand border-side shorthand (e.g., "border-top: 1px solid red")
609
+ def expand_border_side(decl)
610
+ # Extract side from property name (e.g., "border-top" -> "top")
611
+ side = decl.property.byteslice(7..-1) # Skip "border-" prefix
612
+ width, style, color = parse_border_value(decl.value)
613
+ width_prop, style_prop, color_prop = BORDER_SIDE_PROPS[side]
530
614
 
531
- # Split value on whitespace (handling calc() and other functions)
532
- def self.split_on_whitespace(value)
533
- parts = []
534
- current = String.new
535
- paren_depth = 0
536
-
537
- i = 0
538
- len = value.bytesize
539
- while i < len
540
- byte = value.getbyte(i)
541
-
542
- if byte == BYTE_LPAREN
543
- paren_depth += 1
544
- current << byte
545
- elsif byte == BYTE_RPAREN
546
- paren_depth -= 1
547
- current << byte
548
- elsif byte == BYTE_SPACE && paren_depth == 0
549
- parts << current unless current.empty?
550
- current = String.new
551
- else
552
- current << byte
615
+ result = []
616
+ result << Declaration.new(width_prop, width, decl.important) if width
617
+ result << Declaration.new(style_prop, style, decl.important) if style
618
+ result << Declaration.new(color_prop, color, decl.important) if color
619
+
620
+ result
553
621
  end
554
622
 
555
- i += 1
556
- end
623
+ # Expand border-width shorthand
624
+ def expand_border_width(decl)
625
+ sides = parse_four_sides(decl.value)
626
+ [
627
+ Declaration.new(PROP_BORDER_TOP_WIDTH, sides[0], decl.important),
628
+ Declaration.new(PROP_BORDER_RIGHT_WIDTH, sides[1], decl.important),
629
+ Declaration.new(PROP_BORDER_BOTTOM_WIDTH, sides[2], decl.important),
630
+ Declaration.new(PROP_BORDER_LEFT_WIDTH, sides[3], decl.important)
631
+ ]
632
+ end
557
633
 
558
- parts << current unless current.empty?
559
- parts
560
- end
634
+ # Expand border-style shorthand
635
+ def expand_border_style(decl)
636
+ sides = parse_four_sides(decl.value)
637
+ [
638
+ Declaration.new(PROP_BORDER_TOP_STYLE, sides[0], decl.important),
639
+ Declaration.new(PROP_BORDER_RIGHT_STYLE, sides[1], decl.important),
640
+ Declaration.new(PROP_BORDER_BOTTOM_STYLE, sides[2], decl.important),
641
+ Declaration.new(PROP_BORDER_LEFT_STYLE, sides[3], decl.important)
642
+ ]
643
+ end
561
644
 
562
- # Expand border shorthand (e.g., "1px solid red")
563
- def self.expand_border(decl)
564
- # Parse border value
565
- width, style, color = parse_border_value(decl.value)
645
+ # Expand border-color shorthand
646
+ def expand_border_color(decl)
647
+ sides = parse_four_sides(decl.value)
648
+ [
649
+ Declaration.new(PROP_BORDER_TOP_COLOR, sides[0], decl.important),
650
+ Declaration.new(PROP_BORDER_RIGHT_COLOR, sides[1], decl.important),
651
+ Declaration.new(PROP_BORDER_BOTTOM_COLOR, sides[2], decl.important),
652
+ Declaration.new(PROP_BORDER_LEFT_COLOR, sides[3], decl.important)
653
+ ]
654
+ end
566
655
 
567
- result = []
656
+ # Parse border value (e.g., "1px solid red" -> ["1px", "solid", "red"])
657
+ def parse_border_value(value)
658
+ parts = split_on_whitespace(value)
659
+ width = nil
660
+ style = nil
661
+ color = nil
662
+
663
+ # Identify each part by type
664
+ parts.each do |part|
665
+ if is_border_width?(part)
666
+ width = part
667
+ elsif is_border_style?(part)
668
+ style = part
669
+ else
670
+ color = part # Assume color if not width or style
671
+ end
672
+ end
568
673
 
569
- # Expand to all sides using property constants
570
- if width
571
- result << Declaration.new(PROP_BORDER_TOP_WIDTH, width, decl.important)
572
- result << Declaration.new(PROP_BORDER_RIGHT_WIDTH, width, decl.important)
573
- result << Declaration.new(PROP_BORDER_BOTTOM_WIDTH, width, decl.important)
574
- result << Declaration.new(PROP_BORDER_LEFT_WIDTH, width, decl.important)
575
- end
674
+ [width, style, color]
675
+ end
576
676
 
577
- if style
578
- result << Declaration.new(PROP_BORDER_TOP_STYLE, style, decl.important)
579
- result << Declaration.new(PROP_BORDER_RIGHT_STYLE, style, decl.important)
580
- result << Declaration.new(PROP_BORDER_BOTTOM_STYLE, style, decl.important)
581
- result << Declaration.new(PROP_BORDER_LEFT_STYLE, style, decl.important)
582
- end
677
+ # Check if value contains a digit (byte-by-byte)
678
+ def contains_digit?(value)
679
+ i = 0
680
+ len = value.bytesize
681
+ while i < len
682
+ byte = value.getbyte(i)
683
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
583
684
 
584
- if color
585
- result << Declaration.new(PROP_BORDER_TOP_COLOR, color, decl.important)
586
- result << Declaration.new(PROP_BORDER_RIGHT_COLOR, color, decl.important)
587
- result << Declaration.new(PROP_BORDER_BOTTOM_COLOR, color, decl.important)
588
- result << Declaration.new(PROP_BORDER_LEFT_COLOR, color, decl.important)
589
- end
685
+ i += 1
686
+ end
590
687
 
591
- result
592
- end
688
+ false
689
+ end
593
690
 
594
- # Expand border-side shorthand (e.g., "border-top: 1px solid red")
595
- def self.expand_border_side(decl)
596
- # Extract side from property name (e.g., "border-top" -> "top")
597
- side = decl.property.byteslice(7..-1) # Skip "border-" prefix
598
- width, style, color = parse_border_value(decl.value)
599
- width_prop, style_prop, color_prop = BORDER_SIDE_PROPS[side]
691
+ # Check if value looks like a border width
692
+ def is_border_width?(value)
693
+ # Check for numeric values or width keywords
694
+ return true if BORDER_WIDTH_KEYWORDS.include?(value)
600
695
 
601
- result = []
602
- result << Declaration.new(width_prop, width, decl.important) if width
603
- result << Declaration.new(style_prop, style, decl.important) if style
604
- result << Declaration.new(color_prop, color, decl.important) if color
696
+ contains_digit?(value)
697
+ end
605
698
 
606
- result
607
- end
699
+ # Check if value is a border style
700
+ def is_border_style?(value)
701
+ BORDER_STYLE_KEYWORDS.include?(value)
702
+ end
608
703
 
609
- # Expand border-width shorthand
610
- def self.expand_border_width(decl)
611
- sides = parse_four_sides(decl.value)
612
- [
613
- Declaration.new(PROP_BORDER_TOP_WIDTH, sides[0], decl.important),
614
- Declaration.new(PROP_BORDER_RIGHT_WIDTH, sides[1], decl.important),
615
- Declaration.new(PROP_BORDER_BOTTOM_WIDTH, sides[2], decl.important),
616
- Declaration.new(PROP_BORDER_LEFT_WIDTH, sides[3], decl.important)
617
- ]
618
- end
704
+ # Expand font shorthand
705
+ # Format: [style] [variant] [weight] size[/line-height] family
706
+ def expand_font(decl)
707
+ value = decl.value
708
+ parts = split_on_whitespace(value)
709
+
710
+ # Need at least 2 parts (size and family)
711
+ return [decl] if parts.length < 2
712
+
713
+ # Parse from left to right
714
+ # Optional: style, variant, weight (can appear in any order)
715
+ # Required: size (with optional /line-height), family
716
+
717
+ i = 0
718
+ style = nil
719
+ variant = nil
720
+ weight = nil
721
+ size = nil
722
+ line_height = nil
723
+ family_parts = []
724
+
725
+ # Process optional style/variant/weight
726
+ while i < parts.length - 1 # Leave at least 1 for family
727
+ part = parts[i]
728
+
729
+ # Check if this could be size (has digit or is a size keyword)
730
+ if is_font_size?(part)
731
+ # This is the size, rest is family
732
+ size_part = part
733
+
734
+ # Check for line-height (find '/' byte)
735
+ slash_idx = nil
736
+ j = 0
737
+ len = size_part.bytesize
738
+ while j < len
739
+ if size_part.getbyte(j) == BYTE_SLASH
740
+ slash_idx = j
741
+ break
742
+ end
743
+ j += 1
744
+ end
745
+
746
+ if slash_idx
747
+ size = size_part.byteslice(0, slash_idx)
748
+ line_height = size_part.byteslice((slash_idx + 1)..-1)
749
+ else
750
+ size = size_part
751
+ end
752
+
753
+ # Rest is family
754
+ family_parts = parts[(i + 1)..-1]
755
+ break
756
+ elsif is_font_style?(part)
757
+ style = part
758
+ elsif is_font_variant?(part)
759
+ variant = part
760
+ elsif is_font_weight?(part)
761
+ weight = part
762
+ else
763
+ # Unknown, might be start of family - treat everything from here as family
764
+ family_parts = parts[i..-1]
765
+ break
766
+ end
619
767
 
620
- # Expand border-style shorthand
621
- def self.expand_border_style(decl)
622
- sides = parse_four_sides(decl.value)
623
- [
624
- Declaration.new(PROP_BORDER_TOP_STYLE, sides[0], decl.important),
625
- Declaration.new(PROP_BORDER_RIGHT_STYLE, sides[1], decl.important),
626
- Declaration.new(PROP_BORDER_BOTTOM_STYLE, sides[2], decl.important),
627
- Declaration.new(PROP_BORDER_LEFT_STYLE, sides[3], decl.important)
628
- ]
629
- end
768
+ i += 1
769
+ end
630
770
 
631
- # Expand border-color shorthand
632
- def self.expand_border_color(decl)
633
- sides = parse_four_sides(decl.value)
634
- [
635
- Declaration.new(PROP_BORDER_TOP_COLOR, sides[0], decl.important),
636
- Declaration.new(PROP_BORDER_RIGHT_COLOR, sides[1], decl.important),
637
- Declaration.new(PROP_BORDER_BOTTOM_COLOR, sides[2], decl.important),
638
- Declaration.new(PROP_BORDER_LEFT_COLOR, sides[3], decl.important)
639
- ]
640
- end
771
+ family = family_parts.join(' ')
641
772
 
642
- # Parse border value (e.g., "1px solid red" -> ["1px", "solid", "red"])
643
- def self.parse_border_value(value)
644
- parts = split_on_whitespace(value)
645
- width = nil
646
- style = nil
647
- color = nil
648
-
649
- # Identify each part by type
650
- parts.each do |part|
651
- if is_border_width?(part)
652
- width = part
653
- elsif is_border_style?(part)
654
- style = part
655
- else
656
- color = part # Assume color if not width or style
657
- end
658
- end
773
+ # Font shorthand sets ALL longhand properties
774
+ # Unspecified values get CSS initial values
775
+ # Size and family are required; if missing, return unexpanded
776
+ return [decl] if !size || family.empty?
659
777
 
660
- [width, style, color]
661
- end
778
+ result = []
779
+ result << Declaration.new(PROP_FONT_STYLE, style || 'normal', decl.important)
780
+ result << Declaration.new(PROP_FONT_VARIANT, variant || 'normal', decl.important)
781
+ result << Declaration.new(PROP_FONT_WEIGHT, weight || 'normal', decl.important)
782
+ result << Declaration.new(PROP_FONT_SIZE, size, decl.important)
783
+ result << Declaration.new(PROP_LINE_HEIGHT, line_height || 'normal', decl.important)
784
+ result << Declaration.new(PROP_FONT_FAMILY, family, decl.important)
662
785
 
663
- # Check if value contains a digit (byte-by-byte)
664
- def self.contains_digit?(value)
665
- i = 0
666
- len = value.bytesize
667
- while i < len
668
- byte = value.getbyte(i)
669
- return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
786
+ result
787
+ end
670
788
 
671
- i += 1
672
- end
789
+ # Check if value is a font size
790
+ def is_font_size?(value)
791
+ # Has digit or is a keyword
792
+ return true if contains_digit?(value)
673
793
 
674
- false
675
- end
794
+ FONT_SIZE_KEYWORDS.include?(value)
795
+ end
676
796
 
677
- # Check if value looks like a border width
678
- def self.is_border_width?(value)
679
- # Check for numeric values or width keywords
680
- return true if BORDER_WIDTH_KEYWORDS.include?(value)
797
+ # Check if value is a font style
798
+ def is_font_style?(value)
799
+ FONT_STYLE_KEYWORDS.include?(value)
800
+ end
681
801
 
682
- contains_digit?(value)
683
- end
802
+ # Check if value is a font variant
803
+ def is_font_variant?(value)
804
+ FONT_VARIANT_KEYWORDS.include?(value)
805
+ end
684
806
 
685
- # Check if value is a border style
686
- def self.is_border_style?(value)
687
- BORDER_STYLE_KEYWORDS.include?(value)
688
- end
807
+ # Check if value is a font weight
808
+ def is_font_weight?(value)
809
+ # Check for numeric weights like 400, 700
810
+ return true if contains_digit?(value)
689
811
 
690
- # Expand font shorthand
691
- # Format: [style] [variant] [weight] size[/line-height] family
692
- def self.expand_font(decl)
693
- value = decl.value
694
- parts = split_on_whitespace(value)
695
-
696
- # Need at least 2 parts (size and family)
697
- return [decl] if parts.length < 2
698
-
699
- # Parse from left to right
700
- # Optional: style, variant, weight (can appear in any order)
701
- # Required: size (with optional /line-height), family
702
-
703
- i = 0
704
- style = nil
705
- variant = nil
706
- weight = nil
707
- size = nil
708
- line_height = nil
709
- family_parts = []
710
-
711
- # Process optional style/variant/weight
712
- while i < parts.length - 1 # Leave at least 1 for family
713
- part = parts[i]
714
-
715
- # Check if this could be size (has digit or is a size keyword)
716
- if is_font_size?(part)
717
- # This is the size, rest is family
718
- size_part = part
719
-
720
- # Check for line-height (find '/' byte)
721
- slash_idx = nil
722
- j = 0
723
- len = size_part.bytesize
724
- while j < len
725
- if size_part.getbyte(j) == BYTE_SLASH
726
- slash_idx = j
727
- break
812
+ FONT_WEIGHT_KEYWORDS.include?(value)
813
+ end
814
+
815
+ # Expand background shorthand
816
+ # Format: [color] [image] [repeat] [attachment] [position]
817
+ def expand_background(decl)
818
+ value = decl.value
819
+ parts = split_on_whitespace(value)
820
+
821
+ return [decl] if parts.empty?
822
+
823
+ # Parse background components (simple heuristic)
824
+ # According to CSS spec, background shorthand sets ALL properties
825
+ # Any unspecified values get their initial values
826
+ color = nil
827
+ image = nil
828
+ repeat = nil
829
+ attachment = nil
830
+ position = nil
831
+
832
+ parts.each do |part|
833
+ if starts_with_url?(part) || part == 'none'
834
+ image = part
835
+ elsif BACKGROUND_REPEAT_KEYWORDS.include?(part)
836
+ repeat = part
837
+ elsif BACKGROUND_ATTACHMENT_KEYWORDS.include?(part)
838
+ attachment = part
839
+ elsif is_position_value?(part)
840
+ position ||= String.new
841
+ position << ' ' unless position.empty?
842
+ position << part
843
+ else
844
+ # Assume it's a color
845
+ color = part
728
846
  end
729
- j += 1
730
847
  end
731
848
 
732
- if slash_idx
733
- size = size_part.byteslice(0, slash_idx)
734
- line_height = size_part.byteslice((slash_idx + 1)..-1)
735
- else
736
- size = size_part
737
- end
849
+ # Background shorthand sets ALL longhand properties
850
+ # Unspecified values get CSS initial values
851
+ result = []
852
+ result << Declaration.new(PROP_BACKGROUND_COLOR, color || 'transparent', decl.important)
853
+ result << Declaration.new(PROP_BACKGROUND_IMAGE, image || 'none', decl.important)
854
+ result << Declaration.new(PROP_BACKGROUND_REPEAT, repeat || 'repeat', decl.important)
855
+ result << Declaration.new(PROP_BACKGROUND_ATTACHMENT, attachment || 'scroll', decl.important)
856
+ result << Declaration.new(PROP_BACKGROUND_POSITION, position || '0% 0%', decl.important)
738
857
 
739
- # Rest is family
740
- family_parts = parts[(i + 1)..-1]
741
- break
742
- elsif is_font_style?(part)
743
- style = part
744
- elsif is_font_variant?(part)
745
- variant = part
746
- elsif is_font_weight?(part)
747
- weight = part
748
- else
749
- # Unknown, might be start of family - treat everything from here as family
750
- family_parts = parts[i..-1]
751
- break
858
+ result
752
859
  end
753
860
 
754
- i += 1
755
- end
756
-
757
- family = family_parts.join(' ')
861
+ # Check if value starts with 'url('
862
+ def starts_with_url?(value)
863
+ return false if value.bytesize < 4
758
864
 
759
- # Font shorthand sets ALL longhand properties
760
- # Unspecified values get CSS initial values
761
- # Size and family are required; if missing, return unexpanded
762
- return [decl] if !size || family.empty?
865
+ value.getbyte(0) == BYTE_LOWER_U &&
866
+ value.getbyte(1) == BYTE_LOWER_R &&
867
+ value.getbyte(2) == BYTE_LOWER_L &&
868
+ value.getbyte(3) == BYTE_LPAREN
869
+ end
763
870
 
764
- result = []
765
- result << Declaration.new(PROP_FONT_STYLE, style || 'normal', decl.important)
766
- result << Declaration.new(PROP_FONT_VARIANT, variant || 'normal', decl.important)
767
- result << Declaration.new(PROP_FONT_WEIGHT, weight || 'normal', decl.important)
768
- result << Declaration.new(PROP_FONT_SIZE, size, decl.important)
769
- result << Declaration.new(PROP_LINE_HEIGHT, line_height || 'normal', decl.important)
770
- result << Declaration.new(PROP_FONT_FAMILY, family, decl.important)
871
+ # Check if value is a position value (for background-position)
872
+ def is_position_value?(value)
873
+ return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
874
+ return true if contains_digit?(value)
771
875
 
772
- result
773
- end
876
+ # Check for '%'
877
+ i = 0
878
+ len = value.bytesize
879
+ while i < len
880
+ return true if value.getbyte(i) == BYTE_PERCENT
774
881
 
775
- # Check if value is a font size
776
- def self.is_font_size?(value)
777
- # Has digit or is a keyword
778
- return true if contains_digit?(value)
882
+ i += 1
883
+ end
884
+ false
885
+ end
779
886
 
780
- FONT_SIZE_KEYWORDS.include?(value)
781
- end
887
+ # Expand list-style shorthand
888
+ # Format: [type] [position] [image]
889
+ def expand_list_style(decl)
890
+ value = decl.value
891
+ parts = split_on_whitespace(value)
782
892
 
783
- # Check if value is a font style
784
- def self.is_font_style?(value)
785
- FONT_STYLE_KEYWORDS.include?(value)
786
- end
893
+ return [decl] if parts.empty?
787
894
 
788
- # Check if value is a font variant
789
- def self.is_font_variant?(value)
790
- FONT_VARIANT_KEYWORDS.include?(value)
791
- end
895
+ result = []
896
+ type = nil
897
+ position = nil
898
+ image = nil
792
899
 
793
- # Check if value is a font weight
794
- def self.is_font_weight?(value)
795
- # Check for numeric weights like 400, 700
796
- return true if contains_digit?(value)
900
+ parts.each do |part|
901
+ if starts_with_url?(part) || part == 'none'
902
+ image = part
903
+ elsif LIST_STYLE_POSITION_KEYWORDS.include?(part)
904
+ position = part
905
+ else
906
+ # Assume it's a type (disc, circle, square, etc.)
907
+ type = part
908
+ end
909
+ end
797
910
 
798
- FONT_WEIGHT_KEYWORDS.include?(value)
799
- end
911
+ result << Declaration.new(PROP_LIST_STYLE_TYPE, type, decl.important) if type
912
+ result << Declaration.new(PROP_LIST_STYLE_POSITION, position, decl.important) if position
913
+ result << Declaration.new(PROP_LIST_STYLE_IMAGE, image, decl.important) if image
800
914
 
801
- # Expand background shorthand
802
- # Format: [color] [image] [repeat] [attachment] [position]
803
- def self.expand_background(decl)
804
- value = decl.value
805
- parts = split_on_whitespace(value)
806
-
807
- return [decl] if parts.empty?
808
-
809
- # Parse background components (simple heuristic)
810
- # According to CSS spec, background shorthand sets ALL properties
811
- # Any unspecified values get their initial values
812
- color = nil
813
- image = nil
814
- repeat = nil
815
- attachment = nil
816
- position = nil
817
-
818
- parts.each do |part|
819
- if starts_with_url?(part) || part == 'none'
820
- image = part
821
- elsif BACKGROUND_REPEAT_KEYWORDS.include?(part)
822
- repeat = part
823
- elsif BACKGROUND_ATTACHMENT_KEYWORDS.include?(part)
824
- attachment = part
825
- elsif is_position_value?(part)
826
- position ||= String.new
827
- position << ' ' unless position.empty?
828
- position << part
829
- else
830
- # Assume it's a color
831
- color = part
915
+ result.empty? ? [decl] : result
832
916
  end
833
- end
834
917
 
835
- # Background shorthand sets ALL longhand properties
836
- # Unspecified values get CSS initial values
837
- result = []
838
- result << Declaration.new(PROP_BACKGROUND_COLOR, color || 'transparent', decl.important)
839
- result << Declaration.new(PROP_BACKGROUND_IMAGE, image || 'none', decl.important)
840
- result << Declaration.new(PROP_BACKGROUND_REPEAT, repeat || 'repeat', decl.important)
841
- result << Declaration.new(PROP_BACKGROUND_ATTACHMENT, attachment || 'scroll', decl.important)
842
- result << Declaration.new(PROP_BACKGROUND_POSITION, position || '0% 0%', decl.important)
918
+ # Remove the individual longhand declarations a shorthand replaces and
919
+ # append the shorthand declaration in their place.
920
+ # Note: We append rather than insert at original position to match C implementation behavior
921
+ def replace_with_shorthand!(rule, longhand_props, shorthand_prop, value, important)
922
+ rule.declarations.reject! { |d| longhand_props.include?(d.property) }
923
+ rule.declarations << Declaration.new(shorthand_prop, value, important)
924
+ end
843
925
 
844
- result
845
- end
926
+ # Recreate shorthand properties where possible (mutates declarations)
927
+ #
928
+ # @param rule [Rule] Rule to recreate shorthands in
929
+ def recreate_shorthands!(rule)
930
+ # Build property map
931
+ prop_map = {}
932
+ rule.declarations.each { |d| prop_map[d.property] = d }
846
933
 
847
- # Check if value starts with 'url('
848
- def self.starts_with_url?(value)
849
- return false if value.bytesize < 4
934
+ # Try to recreate margin
935
+ recreate_margin!(rule, prop_map)
850
936
 
851
- value.getbyte(0) == BYTE_LOWER_U &&
852
- value.getbyte(1) == BYTE_LOWER_R &&
853
- value.getbyte(2) == BYTE_LOWER_L &&
854
- value.getbyte(3) == BYTE_LPAREN
855
- end
937
+ # Try to recreate padding
938
+ recreate_padding!(rule, prop_map)
856
939
 
857
- # Check if value is a position value (for background-position)
858
- def self.is_position_value?(value)
859
- return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
860
- return true if contains_digit?(value)
940
+ # Try to recreate border
941
+ recreate_border!(rule, prop_map)
861
942
 
862
- # Check for '%'
863
- i = 0
864
- len = value.bytesize
865
- while i < len
866
- return true if value.getbyte(i) == BYTE_PERCENT
943
+ # Try to recreate list-style
944
+ recreate_list_style!(rule, prop_map)
867
945
 
868
- i += 1
869
- end
870
- false
871
- end
946
+ # Try to recreate font
947
+ recreate_font!(rule, prop_map)
872
948
 
873
- # Expand list-style shorthand
874
- # Format: [type] [position] [image]
875
- def self.expand_list_style(decl)
876
- value = decl.value
877
- parts = split_on_whitespace(value)
878
-
879
- return [decl] if parts.empty?
880
-
881
- result = []
882
- type = nil
883
- position = nil
884
- image = nil
885
-
886
- parts.each do |part|
887
- if starts_with_url?(part) || part == 'none'
888
- image = part
889
- elsif LIST_STYLE_POSITION_KEYWORDS.include?(part)
890
- position = part
891
- else
892
- # Assume it's a type (disc, circle, square, etc.)
893
- type = part
949
+ # Try to recreate background
950
+ recreate_background!(rule, prop_map)
894
951
  end
895
- end
896
-
897
- result << Declaration.new(PROP_LIST_STYLE_TYPE, type, decl.important) if type
898
- result << Declaration.new(PROP_LIST_STYLE_POSITION, position, decl.important) if position
899
- result << Declaration.new(PROP_LIST_STYLE_IMAGE, image, decl.important) if image
900
952
 
901
- result.empty? ? [decl] : result
902
- end
953
+ # Try to recreate margin shorthand
954
+ def recreate_margin!(rule, prop_map)
955
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
956
+ sides = []
957
+ MARGIN_SIDES.each { |s| sides << prop_map[s] }
958
+ return unless sides.all? # Need all four sides
959
+
960
+ # Check if all have same importance
961
+ # Use each + << instead of map
962
+ importances = []
963
+ sides.each { |s| importances << s.important }
964
+ importances.uniq!
965
+ return if importances.length > 1 # Mixed importance, can't create shorthand
966
+
967
+ # Use each + << instead of map
968
+ values = []
969
+ sides.each { |s| values << s.value }
970
+ important = sides.first.important
971
+
972
+ # Create optimized shorthand
973
+ shorthand_value = optimize_four_sides(values)
974
+
975
+ replace_with_shorthand!(rule, MARGIN_SIDES, PROP_MARGIN, shorthand_value, important)
976
+ end
903
977
 
904
- # Remove the individual longhand declarations a shorthand replaces and
905
- # append the shorthand declaration in their place.
906
- # Note: We append rather than insert at original position to match C implementation behavior
907
- def self.replace_with_shorthand!(rule, longhand_props, shorthand_prop, value, important)
908
- rule.declarations.reject! { |d| longhand_props.include?(d.property) }
909
- rule.declarations << Declaration.new(shorthand_prop, value, important)
910
- end
978
+ # Try to recreate padding shorthand
979
+ def recreate_padding!(rule, prop_map)
980
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
981
+ sides = []
982
+ PADDING_SIDES.each { |s| sides << prop_map[s] }
983
+ return unless sides.all?
911
984
 
912
- # Recreate shorthand properties where possible (mutates declarations)
913
- #
914
- # @param rule [Rule] Rule to recreate shorthands in
915
- def self.recreate_shorthands!(rule)
916
- # Build property map
917
- prop_map = {}
918
- rule.declarations.each { |d| prop_map[d.property] = d }
985
+ # Use each + << instead of map
986
+ importances = []
987
+ sides.each { |s| importances << s.important }
988
+ importances.uniq!
989
+ return if importances.length > 1
919
990
 
920
- # Try to recreate margin
921
- recreate_margin!(rule, prop_map)
991
+ # Use each + << instead of map
992
+ values = []
993
+ sides.each { |s| values << s.value }
994
+ important = sides.first.important
922
995
 
923
- # Try to recreate padding
924
- recreate_padding!(rule, prop_map)
996
+ shorthand_value = optimize_four_sides(values)
925
997
 
926
- # Try to recreate border
927
- recreate_border!(rule, prop_map)
998
+ replace_with_shorthand!(rule, PADDING_SIDES, PROP_PADDING, shorthand_value, important)
999
+ end
928
1000
 
929
- # Try to recreate list-style
930
- recreate_list_style!(rule, prop_map)
1001
+ # Helper: Check if all declarations have same value and importance
1002
+ # Does single pass instead of multiple .map calls
1003
+ def check_all_same?(decls)
1004
+ return false if decls.empty?
931
1005
 
932
- # Try to recreate font
933
- recreate_font!(rule, prop_map)
1006
+ first_val = decls[0].value
1007
+ first_imp = decls[0].important
934
1008
 
935
- # Try to recreate background
936
- recreate_background!(rule, prop_map)
937
- end
1009
+ i = 1
1010
+ while i < decls.length
1011
+ return false if decls[i].value != first_val
1012
+ return false if decls[i].important != first_imp
938
1013
 
939
- # Try to recreate margin shorthand
940
- def self.recreate_margin!(rule, prop_map)
941
- # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
942
- sides = []
943
- MARGIN_SIDES.each { |s| sides << prop_map[s] }
944
- return unless sides.all? # Need all four sides
945
-
946
- # Check if all have same importance
947
- # Use each + << instead of map
948
- importances = []
949
- sides.each { |s| importances << s.important }
950
- importances.uniq!
951
- return if importances.length > 1 # Mixed importance, can't create shorthand
952
-
953
- # Use each + << instead of map
954
- values = []
955
- sides.each { |s| values << s.value }
956
- important = sides.first.important
957
-
958
- # Create optimized shorthand
959
- shorthand_value = optimize_four_sides(values)
960
-
961
- replace_with_shorthand!(rule, MARGIN_SIDES, PROP_MARGIN, shorthand_value, important)
962
- end
1014
+ i += 1
1015
+ end
963
1016
 
964
- # Try to recreate padding shorthand
965
- def self.recreate_padding!(rule, prop_map)
966
- # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
967
- sides = []
968
- PADDING_SIDES.each { |s| sides << prop_map[s] }
969
- return unless sides.all?
1017
+ true
1018
+ end
970
1019
 
971
- # Use each + << instead of map
972
- importances = []
973
- sides.each { |s| importances << s.important }
974
- importances.uniq!
975
- return if importances.length > 1
1020
+ # Try to recreate border shorthand
1021
+ def recreate_border!(rule, prop_map)
1022
+ # Check if we have all width/style/color properties with same values for all sides
1023
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
1024
+ widths = []
1025
+ BORDER_WIDTHS.each { |p| widths << prop_map[p] }
1026
+ styles = []
1027
+ BORDER_STYLES.each { |p| styles << prop_map[p] }
1028
+ colors = []
1029
+ BORDER_COLORS.each { |p| colors << prop_map[p] }
1030
+
1031
+ # Check if all sides have same values and can create full border shorthand
1032
+ # Check cheapest condition first (.all?), then do single pass for values/importance
1033
+ widths_all_same = widths.all? && check_all_same?(widths)
1034
+ styles_all_same = styles.all? && check_all_same?(styles)
1035
+ colors_all_same = colors.all? && check_all_same?(colors)
1036
+
1037
+ # Can create FULL border shorthand ONLY if style is present (style is required for border shorthand)
1038
+ # AND all properties that are present have same values and importance
1039
+ if styles_all_same
1040
+ # Check if we have width and/or color with same importance as style
1041
+ can_create_border = true
1042
+ important = styles.first.important
1043
+
1044
+ # If width is present, must be all-same and same importance
1045
+ if widths.all?
1046
+ can_create_border = false unless widths_all_same && widths.first.important == important
1047
+ end
976
1048
 
977
- # Use each + << instead of map
978
- values = []
979
- sides.each { |s| values << s.value }
980
- important = sides.first.important
1049
+ # If color is present, must be all-same and same importance
1050
+ if colors.all?
1051
+ can_create_border = false unless colors_all_same && colors.first.important == important
1052
+ end
981
1053
 
982
- shorthand_value = optimize_four_sides(values)
1054
+ if can_create_border
1055
+ # Create full border shorthand
1056
+ parts = []
1057
+ parts << widths.first.value if widths_all_same
1058
+ parts << styles.first.value
1059
+ parts << colors.first.value if colors_all_same
983
1060
 
984
- replace_with_shorthand!(rule, PADDING_SIDES, PROP_PADDING, shorthand_value, important)
985
- end
1061
+ border_value = parts.join(' ')
986
1062
 
987
- # Helper: Check if all declarations have same value and importance
988
- # Does single pass instead of multiple .map calls
989
- def self.check_all_same?(decls)
990
- return false if decls.empty?
1063
+ replace_with_shorthand!(rule, BORDER_ALL, PROP_BORDER, border_value, important)
1064
+ return
1065
+ end
1066
+ end
991
1067
 
992
- first_val = decls[0].value
993
- first_imp = decls[0].important
1068
+ # Try to create border-width/style/color shorthands
1069
+ recreate_border_width!(rule, widths) if widths.all?
1070
+ recreate_border_style!(rule, styles) if styles.all?
1071
+ recreate_border_color!(rule, colors) if colors.all?
1072
+ end
994
1073
 
995
- i = 1
996
- while i < decls.length
997
- return false if decls[i].value != first_val
998
- return false if decls[i].important != first_imp
1074
+ # Recreate border-width shorthand
1075
+ def recreate_border_width!(rule, widths)
1076
+ importances = widths.map(&:important).uniq
1077
+ return if importances.length > 1
999
1078
 
1000
- i += 1
1001
- end
1079
+ values = widths.map(&:value)
1080
+ important = widths.first.important
1002
1081
 
1003
- true
1004
- end
1082
+ shorthand_value = optimize_four_sides(values)
1005
1083
 
1006
- # Try to recreate border shorthand
1007
- def self.recreate_border!(rule, prop_map)
1008
- # Check if we have all width/style/color properties with same values for all sides
1009
- # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
1010
- widths = []
1011
- BORDER_WIDTHS.each { |p| widths << prop_map[p] }
1012
- styles = []
1013
- BORDER_STYLES.each { |p| styles << prop_map[p] }
1014
- colors = []
1015
- BORDER_COLORS.each { |p| colors << prop_map[p] }
1016
-
1017
- # Check if all sides have same values and can create full border shorthand
1018
- # Check cheapest condition first (.all?), then do single pass for values/importance
1019
- widths_all_same = widths.all? && check_all_same?(widths)
1020
- styles_all_same = styles.all? && check_all_same?(styles)
1021
- colors_all_same = colors.all? && check_all_same?(colors)
1022
-
1023
- # Can create FULL border shorthand ONLY if style is present (style is required for border shorthand)
1024
- # AND all properties that are present have same values and importance
1025
- if styles_all_same
1026
- # Check if we have width and/or color with same importance as style
1027
- can_create_border = true
1028
- important = styles.first.important
1029
-
1030
- # If width is present, must be all-same and same importance
1031
- if widths.all?
1032
- can_create_border = false unless widths_all_same && widths.first.important == important
1084
+ replace_with_shorthand!(rule, BORDER_WIDTHS, PROP_BORDER_WIDTH, shorthand_value, important)
1033
1085
  end
1034
1086
 
1035
- # If color is present, must be all-same and same importance
1036
- if colors.all?
1037
- can_create_border = false unless colors_all_same && colors.first.important == important
1038
- end
1087
+ # Recreate border-style shorthand
1088
+ def recreate_border_style!(rule, styles)
1089
+ importances = styles.map(&:important).uniq
1090
+ return if importances.length > 1
1039
1091
 
1040
- if can_create_border
1041
- # Create full border shorthand
1042
- parts = []
1043
- parts << widths.first.value if widths_all_same
1044
- parts << styles.first.value
1045
- parts << colors.first.value if colors_all_same
1092
+ values = styles.map(&:value)
1093
+ important = styles.first.important
1046
1094
 
1047
- border_value = parts.join(' ')
1095
+ shorthand_value = optimize_four_sides(values)
1048
1096
 
1049
- replace_with_shorthand!(rule, BORDER_ALL, PROP_BORDER, border_value, important)
1050
- return
1097
+ replace_with_shorthand!(rule, BORDER_STYLES, PROP_BORDER_STYLE, shorthand_value, important)
1051
1098
  end
1052
- end
1053
-
1054
- # Try to create border-width/style/color shorthands
1055
- recreate_border_width!(rule, widths) if widths.all?
1056
- recreate_border_style!(rule, styles) if styles.all?
1057
- recreate_border_color!(rule, colors) if colors.all?
1058
- end
1059
1099
 
1060
- # Recreate border-width shorthand
1061
- def self.recreate_border_width!(rule, widths)
1062
- importances = widths.map(&:important).uniq
1063
- return if importances.length > 1
1100
+ # Recreate border-color shorthand
1101
+ def recreate_border_color!(rule, colors)
1102
+ importances = colors.map(&:important).uniq
1103
+ return if importances.length > 1
1064
1104
 
1065
- values = widths.map(&:value)
1066
- important = widths.first.important
1105
+ values = colors.map(&:value)
1106
+ important = colors.first.important
1067
1107
 
1068
- shorthand_value = optimize_four_sides(values)
1108
+ shorthand_value = optimize_four_sides(values)
1069
1109
 
1070
- replace_with_shorthand!(rule, BORDER_WIDTHS, PROP_BORDER_WIDTH, shorthand_value, important)
1071
- end
1072
-
1073
- # Recreate border-style shorthand
1074
- def self.recreate_border_style!(rule, styles)
1075
- importances = styles.map(&:important).uniq
1076
- return if importances.length > 1
1110
+ replace_with_shorthand!(rule, BORDER_COLORS, PROP_BORDER_COLOR, shorthand_value, important)
1111
+ end
1077
1112
 
1078
- values = styles.map(&:value)
1079
- important = styles.first.important
1113
+ # Optimize four-sided value representation
1114
+ # ["10px", "10px", "10px", "10px"] -> "10px"
1115
+ # ["10px", "20px", "10px", "20px"] -> "10px 20px"
1116
+ # ["10px", "20px", "30px", "20px"] -> "10px 20px 30px"
1117
+ # ["10px", "20px", "30px", "40px"] -> "10px 20px 30px 40px"
1118
+ def optimize_four_sides(values)
1119
+ top, right, bottom, left = values
1120
+
1121
+ if top == right && right == bottom && bottom == left
1122
+ top
1123
+ elsif top == bottom && right == left
1124
+ "#{top} #{right}"
1125
+ elsif right == left
1126
+ "#{top} #{right} #{bottom}"
1127
+ else
1128
+ "#{top} #{right} #{bottom} #{left}"
1129
+ end
1130
+ end
1080
1131
 
1081
- shorthand_value = optimize_four_sides(values)
1132
+ # Try to recreate font shorthand
1133
+ # Requires: font-size and font-family (minimum)
1134
+ # Optional: font-style, font-variant, font-weight, line-height
1135
+ def recreate_font!(rule, prop_map)
1136
+ size = prop_map[PROP_FONT_SIZE]
1137
+ family = prop_map[PROP_FONT_FAMILY]
1082
1138
 
1083
- replace_with_shorthand!(rule, BORDER_STYLES, PROP_BORDER_STYLE, shorthand_value, important)
1084
- end
1139
+ # Need at least size and family
1140
+ return unless size && family
1085
1141
 
1086
- # Recreate border-color shorthand
1087
- def self.recreate_border_color!(rule, colors)
1088
- importances = colors.map(&:important).uniq
1089
- return if importances.length > 1
1142
+ # Check if all font properties have same importance
1143
+ font_decls = FONT_PROPERTIES.filter_map { |p| prop_map[p] }
1144
+ return if font_decls.empty?
1090
1145
 
1091
- values = colors.map(&:value)
1092
- important = colors.first.important
1146
+ importances = font_decls.map(&:important).uniq
1147
+ return if importances.length > 1
1093
1148
 
1094
- shorthand_value = optimize_four_sides(values)
1149
+ important = font_decls.first.important
1095
1150
 
1096
- replace_with_shorthand!(rule, BORDER_COLORS, PROP_BORDER_COLOR, shorthand_value, important)
1097
- end
1151
+ # Build font shorthand value
1152
+ # Strategy: Only omit defaults if we have ALL 6 properties (from shorthand expansion)
1153
+ # If we have a partial set, include all non-nil values
1154
+ style = prop_map[PROP_FONT_STYLE]&.value
1155
+ variant = prop_map[PROP_FONT_VARIANT]&.value
1156
+ weight = prop_map[PROP_FONT_WEIGHT]&.value
1157
+ line_height = prop_map[PROP_LINE_HEIGHT]&.value
1098
1158
 
1099
- # Optimize four-sided value representation
1100
- # ["10px", "10px", "10px", "10px"] -> "10px"
1101
- # ["10px", "20px", "10px", "20px"] -> "10px 20px"
1102
- # ["10px", "20px", "30px", "20px"] -> "10px 20px 30px"
1103
- # ["10px", "20px", "30px", "40px"] -> "10px 20px 30px 40px"
1104
- def self.optimize_four_sides(values)
1105
- top, right, bottom, left = values
1106
-
1107
- if top == right && right == bottom && bottom == left
1108
- top
1109
- elsif top == bottom && right == left
1110
- "#{top} #{right}"
1111
- elsif right == left
1112
- "#{top} #{right} #{bottom}"
1113
- else
1114
- "#{top} #{right} #{bottom} #{left}"
1115
- end
1116
- end
1117
-
1118
- # Try to recreate font shorthand
1119
- # Requires: font-size and font-family (minimum)
1120
- # Optional: font-style, font-variant, font-weight, line-height
1121
- def self.recreate_font!(rule, prop_map)
1122
- size = prop_map[PROP_FONT_SIZE]
1123
- family = prop_map[PROP_FONT_FAMILY]
1124
-
1125
- # Need at least size and family
1126
- return unless size && family
1127
-
1128
- # Check if all font properties have same importance
1129
- font_decls = FONT_PROPERTIES.filter_map { |p| prop_map[p] }
1130
- return if font_decls.empty?
1131
-
1132
- importances = font_decls.map(&:important).uniq
1133
- return if importances.length > 1
1134
-
1135
- important = font_decls.first.important
1136
-
1137
- # Build font shorthand value
1138
- # Strategy: Only omit defaults if we have ALL 6 properties (from shorthand expansion)
1139
- # If we have a partial set, include all non-nil values
1140
- style = prop_map[PROP_FONT_STYLE]&.value
1141
- variant = prop_map[PROP_FONT_VARIANT]&.value
1142
- weight = prop_map[PROP_FONT_WEIGHT]&.value
1143
- line_height = prop_map[PROP_LINE_HEIGHT]&.value
1144
-
1145
- all_present = style && variant && weight && line_height
1146
- parts = []
1147
-
1148
- if all_present
1149
- # All properties present (likely from shorthand expansion) - omit defaults
1150
- parts << style if style != 'normal'
1151
- parts << variant if variant != 'normal'
1152
- parts << weight if weight != 'normal'
1153
- else
1154
- # Partial set - include all non-nil values
1155
- parts << style if style
1156
- parts << variant if variant
1157
- parts << weight if weight
1158
- end
1159
+ all_present = style && variant && weight && line_height
1160
+ parts = []
1159
1161
 
1160
- # Required: size[/line-height]
1161
- if all_present
1162
- # Omit line-height if default
1163
- if line_height != 'normal'
1164
- parts << "#{size.value}/#{line_height}"
1165
- else
1166
- parts << size.value
1167
- end
1168
- else
1169
- # Include line-height if present
1170
- if line_height
1171
- parts << "#{size.value}/#{line_height}"
1172
- else
1173
- parts << size.value
1174
- end
1175
- end
1162
+ if all_present
1163
+ # All properties present (likely from shorthand expansion) - omit defaults
1164
+ parts << style if style != 'normal'
1165
+ parts << variant if variant != 'normal'
1166
+ parts << weight if weight != 'normal'
1167
+ else
1168
+ # Partial set - include all non-nil values
1169
+ parts << style if style
1170
+ parts << variant if variant
1171
+ parts << weight if weight
1172
+ end
1176
1173
 
1177
- # Required: family
1178
- parts << family.value
1174
+ # Required: size[/line-height]
1175
+ if all_present
1176
+ # Omit line-height if default
1177
+ if line_height != 'normal'
1178
+ parts << "#{size.value}/#{line_height}"
1179
+ else
1180
+ parts << size.value
1181
+ end
1182
+ else
1183
+ # Include line-height if present
1184
+ if line_height
1185
+ parts << "#{size.value}/#{line_height}"
1186
+ else
1187
+ parts << size.value
1188
+ end
1189
+ end
1179
1190
 
1180
- shorthand_value = parts.join(' ')
1191
+ # Required: family
1192
+ parts << family.value
1181
1193
 
1182
- replace_with_shorthand!(rule, FONT_PROPERTIES, PROP_FONT, shorthand_value, important)
1183
- end
1194
+ shorthand_value = parts.join(' ')
1184
1195
 
1185
- # Try to recreate background shorthand
1186
- # Can combine: background-color, background-image, background-repeat, etc.
1187
- def self.recreate_background!(rule, prop_map)
1188
- bg_props = BACKGROUND_PROPERTIES
1189
- bg_decls = bg_props.filter_map { |p| prop_map[p] }
1190
-
1191
- # Need at least 2 properties to create shorthand
1192
- # Single properties should stay as longhands (e.g., background-color: blue)
1193
- # because shorthand resets all other properties to initial values
1194
- return if bg_decls.length < 2
1195
-
1196
- # Check if all have same importance
1197
- importances = bg_decls.map(&:important).uniq
1198
- return if importances.length > 1
1199
-
1200
- important = bg_decls.first.important
1201
-
1202
- # Build background shorthand value
1203
- # Strategy: Only omit defaults if we have ALL 5 properties (from shorthand expansion)
1204
- # If we have a partial set (explicit longhands), include all values
1205
- color = prop_map[PROP_BACKGROUND_COLOR]&.value
1206
- image = prop_map[PROP_BACKGROUND_IMAGE]&.value
1207
- repeat = prop_map[PROP_BACKGROUND_REPEAT]&.value
1208
- position = prop_map[PROP_BACKGROUND_POSITION]&.value
1209
- attachment = prop_map[PROP_BACKGROUND_ATTACHMENT]&.value
1210
-
1211
- all_present = color && image && repeat && position && attachment
1212
- parts = []
1213
-
1214
- if all_present
1215
- # All 5 properties present (likely from shorthand expansion) - omit defaults
1216
- parts << color if color != 'transparent'
1217
- parts << image if image != 'none'
1218
- parts << repeat if repeat != 'repeat'
1219
- parts << position if position != '0% 0%'
1220
- parts << attachment if attachment != 'scroll'
1221
- else
1222
- # Partial set (explicit longhands) - include all non-nil values
1223
- parts << color if color
1224
- parts << image if image
1225
- parts << repeat if repeat
1226
- parts << position if position
1227
- parts << attachment if attachment
1228
- end
1196
+ replace_with_shorthand!(rule, FONT_PROPERTIES, PROP_FONT, shorthand_value, important)
1197
+ end
1229
1198
 
1230
- # If all properties are defaults, the shorthand value would be empty
1231
- # In this case, use "none" which is equivalent to all-default background
1232
- shorthand_value = if parts.empty?
1233
- 'none'
1234
- else
1235
- parts.join(' ')
1236
- end
1199
+ # Try to recreate background shorthand
1200
+ # Can combine: background-color, background-image, background-repeat, etc.
1201
+ def recreate_background!(rule, prop_map)
1202
+ bg_props = BACKGROUND_PROPERTIES
1203
+ bg_decls = bg_props.filter_map { |p| prop_map[p] }
1204
+
1205
+ # Need at least 2 properties to create shorthand
1206
+ # Single properties should stay as longhands (e.g., background-color: blue)
1207
+ # because shorthand resets all other properties to initial values
1208
+ return if bg_decls.length < 2
1209
+
1210
+ # Check if all have same importance
1211
+ importances = bg_decls.map(&:important).uniq
1212
+ return if importances.length > 1
1213
+
1214
+ important = bg_decls.first.important
1215
+
1216
+ # Build background shorthand value
1217
+ # Strategy: Only omit defaults if we have ALL 5 properties (from shorthand expansion)
1218
+ # If we have a partial set (explicit longhands), include all values
1219
+ color = prop_map[PROP_BACKGROUND_COLOR]&.value
1220
+ image = prop_map[PROP_BACKGROUND_IMAGE]&.value
1221
+ repeat = prop_map[PROP_BACKGROUND_REPEAT]&.value
1222
+ position = prop_map[PROP_BACKGROUND_POSITION]&.value
1223
+ attachment = prop_map[PROP_BACKGROUND_ATTACHMENT]&.value
1224
+
1225
+ all_present = color && image && repeat && position && attachment
1226
+ parts = []
1237
1227
 
1238
- replace_with_shorthand!(rule, BACKGROUND_PROPERTIES, PROP_BACKGROUND, shorthand_value, important)
1239
- end
1228
+ if all_present
1229
+ # All 5 properties present (likely from shorthand expansion) - omit defaults
1230
+ parts << color if color != 'transparent'
1231
+ parts << image if image != 'none'
1232
+ parts << repeat if repeat != 'repeat'
1233
+ parts << position if position != '0% 0%'
1234
+ parts << attachment if attachment != 'scroll'
1235
+ else
1236
+ # Partial set (explicit longhands) - include all non-nil values
1237
+ parts << color if color
1238
+ parts << image if image
1239
+ parts << repeat if repeat
1240
+ parts << position if position
1241
+ parts << attachment if attachment
1242
+ end
1240
1243
 
1241
- # Try to recreate list-style shorthand
1242
- # Can combine: list-style-type, list-style-position, list-style-image
1243
- def self.recreate_list_style!(rule, prop_map)
1244
- ls_props = LIST_STYLE_PROPERTIES
1245
- ls_decls = ls_props.filter_map { |p| prop_map[p] }
1244
+ # If all properties are defaults, the shorthand value would be empty
1245
+ # In this case, use "none" which is equivalent to all-default background
1246
+ shorthand_value = if parts.empty?
1247
+ 'none'
1248
+ else
1249
+ parts.join(' ')
1250
+ end
1246
1251
 
1247
- # Need at least 2 properties to create shorthand
1248
- return if ls_decls.length < 2
1252
+ replace_with_shorthand!(rule, BACKGROUND_PROPERTIES, PROP_BACKGROUND, shorthand_value, important)
1253
+ end
1249
1254
 
1250
- # Check if all have same importance
1251
- importances = ls_decls.map(&:important).uniq
1252
- return if importances.length > 1
1255
+ # Try to recreate list-style shorthand
1256
+ # Can combine: list-style-type, list-style-position, list-style-image
1257
+ def recreate_list_style!(rule, prop_map)
1258
+ ls_props = LIST_STYLE_PROPERTIES
1259
+ ls_decls = ls_props.filter_map { |p| prop_map[p] }
1253
1260
 
1254
- important = ls_decls.first.important
1261
+ # Need at least 2 properties to create shorthand
1262
+ return if ls_decls.length < 2
1255
1263
 
1256
- # Build list-style shorthand value
1257
- parts = []
1258
- parts << prop_map[PROP_LIST_STYLE_TYPE].value if prop_map[PROP_LIST_STYLE_TYPE]
1259
- parts << prop_map[PROP_LIST_STYLE_POSITION].value if prop_map[PROP_LIST_STYLE_POSITION]
1260
- parts << prop_map[PROP_LIST_STYLE_IMAGE].value if prop_map[PROP_LIST_STYLE_IMAGE]
1264
+ # Check if all have same importance
1265
+ importances = ls_decls.map(&:important).uniq
1266
+ return if importances.length > 1
1261
1267
 
1262
- shorthand_value = parts.join(' ')
1268
+ important = ls_decls.first.important
1263
1269
 
1264
- replace_with_shorthand!(rule, LIST_STYLE_PROPERTIES, PROP_LIST_STYLE, shorthand_value, important)
1265
- end
1270
+ # Build list-style shorthand value
1271
+ parts = []
1272
+ parts << prop_map[PROP_LIST_STYLE_TYPE].value if prop_map[PROP_LIST_STYLE_TYPE]
1273
+ parts << prop_map[PROP_LIST_STYLE_POSITION].value if prop_map[PROP_LIST_STYLE_POSITION]
1274
+ parts << prop_map[PROP_LIST_STYLE_IMAGE].value if prop_map[PROP_LIST_STYLE_IMAGE]
1266
1275
 
1267
- # Update selector lists to remove diverged rules
1268
- #
1269
- # After flattening/cascade, rules that were in the same selector list may have
1270
- # different declarations. This method builds the selector_lists hash with only
1271
- # rules that still match, and clears selector_list_id for diverged rules.
1272
- #
1273
- # @param merged_rules [Array<Rule>] Flattened rules (with new IDs assigned)
1274
- # @param selector_lists [Hash] Empty hash to populate with list_id => Array of rule IDs
1275
- def self.update_selector_lists_for_divergence!(merged_rules, selector_lists)
1276
- # Group merged rules by selector_list_id (skip rules with no list)
1277
- # Note: Using manual each loop instead of .select{}.group_by for performance.
1278
- # The more concise form (.select + .group_by) is ~50-60% slower due to intermediate array allocation.
1279
- rules_by_list = {}
1280
- merged_rules.each do |r|
1281
- next unless r.selector_list_id
1282
-
1283
- (rules_by_list[r.selector_list_id] ||= []) << r
1284
- end
1276
+ shorthand_value = parts.join(' ')
1285
1277
 
1286
- # For each selector list, check if declarations still match
1287
- rules_by_list.each do |list_id, rules_in_list|
1288
- # Skip if only one rule in list (nothing to compare)
1289
- next if rules_in_list.size <= 1
1278
+ replace_with_shorthand!(rule, LIST_STYLE_PROPERTIES, PROP_LIST_STYLE, shorthand_value, important)
1279
+ end
1290
1280
 
1291
- # Get first rule as reference
1292
- reference_rule = rules_in_list.first
1293
- reference_decls = reference_rule.declarations
1281
+ # Update selector lists to remove diverged rules
1282
+ #
1283
+ # After flattening/cascade, rules that were in the same selector list may have
1284
+ # different declarations. This method builds the selector_lists hash with only
1285
+ # rules that still match, and clears selector_list_id for diverged rules.
1286
+ #
1287
+ # @param merged_rules [Array<Rule>] Flattened rules (with new IDs assigned)
1288
+ # @param selector_lists [Hash] Empty hash to populate with list_id => Array of rule IDs
1289
+ def update_selector_lists_for_divergence!(merged_rules, selector_lists)
1290
+ # Group merged rules by selector_list_id (skip rules with no list)
1291
+ # Note: Using manual each loop instead of .select{}.group_by for performance.
1292
+ # The more concise form (.select + .group_by) is ~50-60% slower due to intermediate array allocation.
1293
+ rules_by_list = {}
1294
+ merged_rules.each do |r|
1295
+ next unless r.selector_list_id
1296
+
1297
+ (rules_by_list[r.selector_list_id] ||= []) << r
1298
+ end
1294
1299
 
1295
- # Find rules that still match (have identical declarations)
1296
- matching_rules = [reference_rule]
1300
+ # For each selector list, check if declarations still match
1301
+ rules_by_list.each do |list_id, rules_in_list|
1302
+ # Skip if only one rule in list (nothing to compare)
1303
+ next if rules_in_list.size <= 1
1304
+
1305
+ # Get first rule as reference
1306
+ reference_rule = rules_in_list.first
1307
+ reference_decls = reference_rule.declarations
1308
+
1309
+ # Find rules that still match (have identical declarations)
1310
+ matching_rules = [reference_rule]
1311
+
1312
+ rules_in_list[1..].each do |rule|
1313
+ if declarations_equal?(reference_decls, rule.declarations)
1314
+ matching_rules << rule
1315
+ else
1316
+ # Clear selector_list_id for diverged rule
1317
+ rule.selector_list_id = nil
1318
+ end
1319
+ end
1297
1320
 
1298
- rules_in_list[1..].each do |rule|
1299
- if declarations_equal?(reference_decls, rule.declarations)
1300
- matching_rules << rule
1301
- else
1302
- # Clear selector_list_id for diverged rule
1303
- rule.selector_list_id = nil
1321
+ # Only keep the selector list if at least 2 rules still match
1322
+ if matching_rules.size >= 2
1323
+ # Build selector_lists hash with NEW rule IDs
1324
+ selector_lists[list_id] = matching_rules.map(&:id)
1325
+ else
1326
+ # Clear selector_list_id for the last remaining rule too
1327
+ matching_rules.each { |r| r.selector_list_id = nil }
1328
+ end
1304
1329
  end
1305
1330
  end
1306
1331
 
1307
- # Only keep the selector list if at least 2 rules still match
1308
- if matching_rules.size >= 2
1309
- # Build selector_lists hash with NEW rule IDs
1310
- selector_lists[list_id] = matching_rules.map(&:id)
1311
- else
1312
- # Clear selector_list_id for the last remaining rule too
1313
- matching_rules.each { |r| r.selector_list_id = nil }
1332
+ # Check if two declaration arrays are identical
1333
+ #
1334
+ # @param decls1 [Array<Declaration>]
1335
+ # @param decls2 [Array<Declaration>]
1336
+ # @return [Boolean]
1337
+ def declarations_equal?(decls1, decls2)
1338
+ return false if decls1.size != decls2.size
1339
+
1340
+ # Compare each declaration (property, value, important must all match)
1341
+ decls1.zip(decls2).all? do |d1, d2|
1342
+ d1.property == d2.property &&
1343
+ d1.value == d2.value &&
1344
+ d1.important == d2.important
1345
+ end
1314
1346
  end
1315
- end
1316
- end
1317
1347
 
1318
- # Check if two declaration arrays are identical
1319
- #
1320
- # @param decls1 [Array<Declaration>]
1321
- # @param decls2 [Array<Declaration>]
1322
- # @return [Boolean]
1323
- def self.declarations_equal?(decls1, decls2)
1324
- return false if decls1.size != decls2.size
1325
-
1326
- # Compare each declaration (property, value, important must all match)
1327
- decls1.zip(decls2).all? do |d1, d2|
1328
- d1.property == d2.property &&
1329
- d1.value == d2.value &&
1330
- d1.important == d2.important
1348
+ # Mark all methods except flatten and expand_shorthand as private
1349
+ private :flatten_rules_for_selector, :calculate_specificity,
1350
+ :expand_margin, :expand_padding, :parse_four_sides, :split_on_whitespace,
1351
+ :expand_border, :expand_border_side, :expand_border_width, :expand_border_style,
1352
+ :expand_border_color, :parse_border_value, :contains_digit?, :is_border_width?,
1353
+ :is_border_style?,
1354
+ :expand_font, :is_font_size?, :is_font_style?, :is_font_variant?, :is_font_weight?,
1355
+ :expand_background, :starts_with_url?, :is_position_value?, :expand_list_style,
1356
+ :replace_with_shorthand!, :recreate_shorthands!, :recreate_margin!, :recreate_padding!,
1357
+ :check_all_same?,
1358
+ :recreate_border!, :recreate_border_width!, :recreate_border_style!, :recreate_border_color!,
1359
+ :optimize_four_sides, :recreate_font!, :recreate_background!, :recreate_list_style!,
1360
+ :update_selector_lists_for_divergence!, :declarations_equal?,
1361
+ :partition_at_rules, :expand_all_shorthands!, :merge_rules_by_selector_and_media,
1362
+ :build_selector_lists, :rebuild_media_index, :build_result
1331
1363
  end
1332
- end
1333
1364
 
1334
- # Mark all methods except flatten and expand_shorthand as private
1335
- private_class_method :flatten_rules_for_selector, :calculate_specificity,
1336
- :expand_margin, :expand_padding, :parse_four_sides, :split_on_whitespace,
1337
- :expand_border, :expand_border_side, :expand_border_width, :expand_border_style,
1338
- :expand_border_color, :parse_border_value, :contains_digit?, :is_border_width?,
1339
- :is_border_style?,
1340
- :expand_font, :is_font_size?, :is_font_style?, :is_font_variant?, :is_font_weight?,
1341
- :expand_background, :starts_with_url?, :is_position_value?, :expand_list_style,
1342
- :replace_with_shorthand!, :recreate_shorthands!, :recreate_margin!, :recreate_padding!,
1343
- :check_all_same?,
1344
- :recreate_border!, :recreate_border_width!, :recreate_border_style!, :recreate_border_color!,
1345
- :optimize_four_sides, :recreate_font!, :recreate_background!, :recreate_list_style!,
1346
- :update_selector_lists_for_divergence!, :declarations_equal?,
1347
- :partition_at_rules, :expand_all_shorthands!, :merge_rules_by_selector_and_media,
1348
- :build_selector_lists, :rebuild_media_index, :build_result
1365
+ # A frozen singleton, same rationale as Pure itself (see pure.rb) - none of
1366
+ # FlattenImpl's methods touch instance state, so one shared instance is as
1367
+ # safe as the bare module this replaces.
1368
+ Flatten = FlattenImpl.new.freeze
1369
+ end
1349
1370
  end
1350
1371
  end