cataract 0.3.0 → 0.4.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,1356 @@
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
+ # Create merged rule
442
+ Rule.new(
443
+ 0, # ID will be updated later
444
+ selector,
445
+ declarations,
446
+ rules.first.specificity, # Use first rule's specificity
447
+ nil, # No parent after flattening
448
+ nil, # No nesting style after flattening
449
+ selector_list_id, # Preserve if all rules share same ID
450
+ media_query_id # Preserve media context
451
+ )
452
+ end
361
453
 
362
- rule.declarations.each_with_index do |decl, idx|
363
- # Property is already US-ASCII and lowercase from parser
364
- prop = decl.property
454
+ # Calculate specificity for a selector
455
+ #
456
+ # @param selector [String] CSS selector
457
+ # @return [Integer] Specificity value
458
+ def calculate_specificity(selector)
459
+ Pure.calculate_specificity(selector)
460
+ end
365
461
 
366
- # Calculate source order (higher = later)
367
- source_order = rule.id * 1000 + idx
462
+ # Expand a single shorthand declaration into longhand declarations.
463
+ # Returns an array of longhand declarations. If the declaration is not a shorthand,
464
+ # returns an array with just that declaration.
465
+ #
466
+ # @param decl [Declaration] Declaration to expand
467
+ # @return [Array<Declaration>] Array of expanded longhand declarations
468
+ # @api private
469
+ def expand_shorthand(decl)
470
+ case decl.property
471
+ when 'margin'
472
+ expand_margin(decl)
473
+ when 'padding'
474
+ expand_padding(decl)
475
+ when 'border'
476
+ expand_border(decl)
477
+ when 'border-top', 'border-right', 'border-bottom', 'border-left'
478
+ expand_border_side(decl)
479
+ when 'border-width'
480
+ expand_border_width(decl)
481
+ when 'border-style'
482
+ expand_border_style(decl)
483
+ when 'border-color'
484
+ expand_border_color(decl)
485
+ when 'font'
486
+ expand_font(decl)
487
+ when 'background'
488
+ expand_background(decl)
489
+ when 'list-style'
490
+ expand_list_style(decl)
491
+ else
492
+ # Not a shorthand, return as-is in an array
493
+ [decl]
494
+ end
495
+ end
368
496
 
369
- existing = decl_map[prop]
497
+ # Expand margin shorthand
498
+ def expand_margin(decl)
499
+ sides = parse_four_sides(decl.value)
500
+ [
501
+ Declaration.new(PROP_MARGIN_TOP, sides[0], decl.important),
502
+ Declaration.new(PROP_MARGIN_RIGHT, sides[1], decl.important),
503
+ Declaration.new(PROP_MARGIN_BOTTOM, sides[2], decl.important),
504
+ Declaration.new(PROP_MARGIN_LEFT, sides[3], decl.important)
505
+ ]
506
+ end
370
507
 
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)
508
+ # Expand padding shorthand
509
+ def expand_padding(decl)
510
+ sides = parse_four_sides(decl.value)
511
+ [
512
+ Declaration.new(PROP_PADDING_TOP, sides[0], decl.important),
513
+ Declaration.new(PROP_PADDING_RIGHT, sides[1], decl.important),
514
+ Declaration.new(PROP_PADDING_BOTTOM, sides[2], decl.important),
515
+ Declaration.new(PROP_PADDING_LEFT, sides[3], decl.important)
516
+ ]
517
+ end
375
518
 
376
- if existing.nil?
377
- decl_map[prop] = [source_order, spec, decl.important, decl.value]
519
+ # Parse four-sided value (margin/padding)
520
+ # "10px" -> ["10px", "10px", "10px", "10px"]
521
+ # "10px 20px" -> ["10px", "20px", "10px", "20px"]
522
+ # "10px 20px 30px" -> ["10px", "20px", "30px", "20px"]
523
+ # "10px 20px 30px 40px" -> ["10px", "20px", "30px", "40px"]
524
+ def parse_four_sides(value)
525
+ parts = split_on_whitespace(value)
526
+
527
+ case parts.length
528
+ when 1
529
+ [parts[0], parts[0], parts[0], parts[0]]
530
+ when 2
531
+ [parts[0], parts[1], parts[0], parts[1]]
532
+ when 3
533
+ [parts[0], parts[1], parts[2], parts[1]]
378
534
  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
535
+ [parts[0], parts[1], parts[2], parts[3]]
536
+ end
537
+ end
538
+
539
+ # Split value on whitespace (handling calc() and other functions)
540
+ def split_on_whitespace(value)
541
+ parts = []
542
+ current = String.new
543
+ paren_depth = 0
544
+
545
+ i = 0
546
+ len = value.bytesize
547
+ while i < len
548
+ byte = value.getbyte(i)
549
+
550
+ if byte == BYTE_LPAREN
551
+ paren_depth += 1
552
+ current << byte
553
+ elsif byte == BYTE_RPAREN
554
+ paren_depth -= 1
555
+ current << byte
556
+ elsif byte == BYTE_SPACE && paren_depth == 0
557
+ parts << current unless current.empty?
558
+ current = String.new
396
559
  else
397
- # Same specificity and importance -> later source order wins
398
- should_replace = source_order > existing_order
560
+ current << byte
399
561
  end
400
562
 
401
- if should_replace
402
- decl_map[prop] = [source_order, spec, decl.important, decl.value]
403
- end
563
+ i += 1
404
564
  end
565
+
566
+ parts << current unless current.empty?
567
+ parts
405
568
  end
406
- end
407
569
 
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
570
+ # Expand border shorthand (e.g., "1px solid red")
571
+ def expand_border(decl)
572
+ # Parse border value
573
+ width, style, color = parse_border_value(decl.value)
422
574
 
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
575
+ result = []
445
576
 
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
577
+ # Expand to all sides using property constants
578
+ if width
579
+ result << Declaration.new(PROP_BORDER_TOP_WIDTH, width, decl.important)
580
+ result << Declaration.new(PROP_BORDER_RIGHT_WIDTH, width, decl.important)
581
+ result << Declaration.new(PROP_BORDER_BOTTOM_WIDTH, width, decl.important)
582
+ result << Declaration.new(PROP_BORDER_LEFT_WIDTH, width, decl.important)
583
+ end
453
584
 
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
585
+ if style
586
+ result << Declaration.new(PROP_BORDER_TOP_STYLE, style, decl.important)
587
+ result << Declaration.new(PROP_BORDER_RIGHT_STYLE, style, decl.important)
588
+ result << Declaration.new(PROP_BORDER_BOTTOM_STYLE, style, decl.important)
589
+ result << Declaration.new(PROP_BORDER_LEFT_STYLE, style, decl.important)
590
+ end
488
591
 
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
592
+ if color
593
+ result << Declaration.new(PROP_BORDER_TOP_COLOR, color, decl.important)
594
+ result << Declaration.new(PROP_BORDER_RIGHT_COLOR, color, decl.important)
595
+ result << Declaration.new(PROP_BORDER_BOTTOM_COLOR, color, decl.important)
596
+ result << Declaration.new(PROP_BORDER_LEFT_COLOR, color, decl.important)
597
+ end
499
598
 
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
599
+ result
600
+ end
510
601
 
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
602
+ # Expand border-side shorthand (e.g., "border-top: 1px solid red")
603
+ def expand_border_side(decl)
604
+ # Extract side from property name (e.g., "border-top" -> "top")
605
+ side = decl.property.byteslice(7..-1) # Skip "border-" prefix
606
+ width, style, color = parse_border_value(decl.value)
607
+ width_prop, style_prop, color_prop = BORDER_SIDE_PROPS[side]
530
608
 
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
609
+ result = []
610
+ result << Declaration.new(width_prop, width, decl.important) if width
611
+ result << Declaration.new(style_prop, style, decl.important) if style
612
+ result << Declaration.new(color_prop, color, decl.important) if color
613
+
614
+ result
553
615
  end
554
616
 
555
- i += 1
556
- end
617
+ # Expand border-width shorthand
618
+ def expand_border_width(decl)
619
+ sides = parse_four_sides(decl.value)
620
+ [
621
+ Declaration.new(PROP_BORDER_TOP_WIDTH, sides[0], decl.important),
622
+ Declaration.new(PROP_BORDER_RIGHT_WIDTH, sides[1], decl.important),
623
+ Declaration.new(PROP_BORDER_BOTTOM_WIDTH, sides[2], decl.important),
624
+ Declaration.new(PROP_BORDER_LEFT_WIDTH, sides[3], decl.important)
625
+ ]
626
+ end
557
627
 
558
- parts << current unless current.empty?
559
- parts
560
- end
628
+ # Expand border-style shorthand
629
+ def expand_border_style(decl)
630
+ sides = parse_four_sides(decl.value)
631
+ [
632
+ Declaration.new(PROP_BORDER_TOP_STYLE, sides[0], decl.important),
633
+ Declaration.new(PROP_BORDER_RIGHT_STYLE, sides[1], decl.important),
634
+ Declaration.new(PROP_BORDER_BOTTOM_STYLE, sides[2], decl.important),
635
+ Declaration.new(PROP_BORDER_LEFT_STYLE, sides[3], decl.important)
636
+ ]
637
+ end
561
638
 
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)
639
+ # Expand border-color shorthand
640
+ def expand_border_color(decl)
641
+ sides = parse_four_sides(decl.value)
642
+ [
643
+ Declaration.new(PROP_BORDER_TOP_COLOR, sides[0], decl.important),
644
+ Declaration.new(PROP_BORDER_RIGHT_COLOR, sides[1], decl.important),
645
+ Declaration.new(PROP_BORDER_BOTTOM_COLOR, sides[2], decl.important),
646
+ Declaration.new(PROP_BORDER_LEFT_COLOR, sides[3], decl.important)
647
+ ]
648
+ end
566
649
 
567
- result = []
650
+ # Parse border value (e.g., "1px solid red" -> ["1px", "solid", "red"])
651
+ def parse_border_value(value)
652
+ parts = split_on_whitespace(value)
653
+ width = nil
654
+ style = nil
655
+ color = nil
656
+
657
+ # Identify each part by type
658
+ parts.each do |part|
659
+ if is_border_width?(part)
660
+ width = part
661
+ elsif is_border_style?(part)
662
+ style = part
663
+ else
664
+ color = part # Assume color if not width or style
665
+ end
666
+ end
568
667
 
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
668
+ [width, style, color]
669
+ end
576
670
 
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
671
+ # Check if value contains a digit (byte-by-byte)
672
+ def contains_digit?(value)
673
+ i = 0
674
+ len = value.bytesize
675
+ while i < len
676
+ byte = value.getbyte(i)
677
+ return true if byte >= BYTE_DIGIT_0 && byte <= BYTE_DIGIT_9
583
678
 
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
679
+ i += 1
680
+ end
590
681
 
591
- result
592
- end
682
+ false
683
+ end
593
684
 
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]
685
+ # Check if value looks like a border width
686
+ def is_border_width?(value)
687
+ # Check for numeric values or width keywords
688
+ return true if BORDER_WIDTH_KEYWORDS.include?(value)
600
689
 
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
690
+ contains_digit?(value)
691
+ end
605
692
 
606
- result
607
- end
693
+ # Check if value is a border style
694
+ def is_border_style?(value)
695
+ BORDER_STYLE_KEYWORDS.include?(value)
696
+ end
608
697
 
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
698
+ # Expand font shorthand
699
+ # Format: [style] [variant] [weight] size[/line-height] family
700
+ def expand_font(decl)
701
+ value = decl.value
702
+ parts = split_on_whitespace(value)
703
+
704
+ # Need at least 2 parts (size and family)
705
+ return [decl] if parts.length < 2
706
+
707
+ # Parse from left to right
708
+ # Optional: style, variant, weight (can appear in any order)
709
+ # Required: size (with optional /line-height), family
710
+
711
+ i = 0
712
+ style = nil
713
+ variant = nil
714
+ weight = nil
715
+ size = nil
716
+ line_height = nil
717
+ family_parts = []
718
+
719
+ # Process optional style/variant/weight
720
+ while i < parts.length - 1 # Leave at least 1 for family
721
+ part = parts[i]
722
+
723
+ # Check if this could be size (has digit or is a size keyword)
724
+ if is_font_size?(part)
725
+ # This is the size, rest is family
726
+ size_part = part
727
+
728
+ # Check for line-height (find '/' byte)
729
+ slash_idx = nil
730
+ j = 0
731
+ len = size_part.bytesize
732
+ while j < len
733
+ if size_part.getbyte(j) == BYTE_SLASH
734
+ slash_idx = j
735
+ break
736
+ end
737
+ j += 1
738
+ end
739
+
740
+ if slash_idx
741
+ size = size_part.byteslice(0, slash_idx)
742
+ line_height = size_part.byteslice((slash_idx + 1)..-1)
743
+ else
744
+ size = size_part
745
+ end
746
+
747
+ # Rest is family
748
+ family_parts = parts[(i + 1)..-1]
749
+ break
750
+ elsif is_font_style?(part)
751
+ style = part
752
+ elsif is_font_variant?(part)
753
+ variant = part
754
+ elsif is_font_weight?(part)
755
+ weight = part
756
+ else
757
+ # Unknown, might be start of family - treat everything from here as family
758
+ family_parts = parts[i..-1]
759
+ break
760
+ end
619
761
 
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
762
+ i += 1
763
+ end
630
764
 
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
765
+ family = family_parts.join(' ')
641
766
 
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
767
+ # Font shorthand sets ALL longhand properties
768
+ # Unspecified values get CSS initial values
769
+ # Size and family are required; if missing, return unexpanded
770
+ return [decl] if !size || family.empty?
659
771
 
660
- [width, style, color]
661
- end
772
+ result = []
773
+ result << Declaration.new(PROP_FONT_STYLE, style || 'normal', decl.important)
774
+ result << Declaration.new(PROP_FONT_VARIANT, variant || 'normal', decl.important)
775
+ result << Declaration.new(PROP_FONT_WEIGHT, weight || 'normal', decl.important)
776
+ result << Declaration.new(PROP_FONT_SIZE, size, decl.important)
777
+ result << Declaration.new(PROP_LINE_HEIGHT, line_height || 'normal', decl.important)
778
+ result << Declaration.new(PROP_FONT_FAMILY, family, decl.important)
662
779
 
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
780
+ result
781
+ end
670
782
 
671
- i += 1
672
- end
783
+ # Check if value is a font size
784
+ def is_font_size?(value)
785
+ # Has digit or is a keyword
786
+ return true if contains_digit?(value)
673
787
 
674
- false
675
- end
788
+ FONT_SIZE_KEYWORDS.include?(value)
789
+ end
676
790
 
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)
791
+ # Check if value is a font style
792
+ def is_font_style?(value)
793
+ FONT_STYLE_KEYWORDS.include?(value)
794
+ end
681
795
 
682
- contains_digit?(value)
683
- end
796
+ # Check if value is a font variant
797
+ def is_font_variant?(value)
798
+ FONT_VARIANT_KEYWORDS.include?(value)
799
+ end
684
800
 
685
- # Check if value is a border style
686
- def self.is_border_style?(value)
687
- BORDER_STYLE_KEYWORDS.include?(value)
688
- end
801
+ # Check if value is a font weight
802
+ def is_font_weight?(value)
803
+ # Check for numeric weights like 400, 700
804
+ return true if contains_digit?(value)
689
805
 
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
806
+ FONT_WEIGHT_KEYWORDS.include?(value)
807
+ end
808
+
809
+ # Expand background shorthand
810
+ # Format: [color] [image] [repeat] [attachment] [position]
811
+ def expand_background(decl)
812
+ value = decl.value
813
+ parts = split_on_whitespace(value)
814
+
815
+ return [decl] if parts.empty?
816
+
817
+ # Parse background components (simple heuristic)
818
+ # According to CSS spec, background shorthand sets ALL properties
819
+ # Any unspecified values get their initial values
820
+ color = nil
821
+ image = nil
822
+ repeat = nil
823
+ attachment = nil
824
+ position = nil
825
+
826
+ parts.each do |part|
827
+ if starts_with_url?(part) || part == 'none'
828
+ image = part
829
+ elsif BACKGROUND_REPEAT_KEYWORDS.include?(part)
830
+ repeat = part
831
+ elsif BACKGROUND_ATTACHMENT_KEYWORDS.include?(part)
832
+ attachment = part
833
+ elsif is_position_value?(part)
834
+ position ||= String.new
835
+ position << ' ' unless position.empty?
836
+ position << part
837
+ else
838
+ # Assume it's a color
839
+ color = part
728
840
  end
729
- j += 1
730
841
  end
731
842
 
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
843
+ # Background shorthand sets ALL longhand properties
844
+ # Unspecified values get CSS initial values
845
+ result = []
846
+ result << Declaration.new(PROP_BACKGROUND_COLOR, color || 'transparent', decl.important)
847
+ result << Declaration.new(PROP_BACKGROUND_IMAGE, image || 'none', decl.important)
848
+ result << Declaration.new(PROP_BACKGROUND_REPEAT, repeat || 'repeat', decl.important)
849
+ result << Declaration.new(PROP_BACKGROUND_ATTACHMENT, attachment || 'scroll', decl.important)
850
+ result << Declaration.new(PROP_BACKGROUND_POSITION, position || '0% 0%', decl.important)
738
851
 
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
852
+ result
752
853
  end
753
854
 
754
- i += 1
755
- end
756
-
757
- family = family_parts.join(' ')
855
+ # Check if value starts with 'url('
856
+ def starts_with_url?(value)
857
+ return false if value.bytesize < 4
758
858
 
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?
859
+ value.getbyte(0) == BYTE_LOWER_U &&
860
+ value.getbyte(1) == BYTE_LOWER_R &&
861
+ value.getbyte(2) == BYTE_LOWER_L &&
862
+ value.getbyte(3) == BYTE_LPAREN
863
+ end
763
864
 
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)
865
+ # Check if value is a position value (for background-position)
866
+ def is_position_value?(value)
867
+ return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
868
+ return true if contains_digit?(value)
771
869
 
772
- result
773
- end
870
+ # Check for '%'
871
+ i = 0
872
+ len = value.bytesize
873
+ while i < len
874
+ return true if value.getbyte(i) == BYTE_PERCENT
774
875
 
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)
876
+ i += 1
877
+ end
878
+ false
879
+ end
779
880
 
780
- FONT_SIZE_KEYWORDS.include?(value)
781
- end
881
+ # Expand list-style shorthand
882
+ # Format: [type] [position] [image]
883
+ def expand_list_style(decl)
884
+ value = decl.value
885
+ parts = split_on_whitespace(value)
782
886
 
783
- # Check if value is a font style
784
- def self.is_font_style?(value)
785
- FONT_STYLE_KEYWORDS.include?(value)
786
- end
887
+ return [decl] if parts.empty?
787
888
 
788
- # Check if value is a font variant
789
- def self.is_font_variant?(value)
790
- FONT_VARIANT_KEYWORDS.include?(value)
791
- end
889
+ result = []
890
+ type = nil
891
+ position = nil
892
+ image = nil
792
893
 
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)
894
+ parts.each do |part|
895
+ if starts_with_url?(part) || part == 'none'
896
+ image = part
897
+ elsif LIST_STYLE_POSITION_KEYWORDS.include?(part)
898
+ position = part
899
+ else
900
+ # Assume it's a type (disc, circle, square, etc.)
901
+ type = part
902
+ end
903
+ end
797
904
 
798
- FONT_WEIGHT_KEYWORDS.include?(value)
799
- end
905
+ result << Declaration.new(PROP_LIST_STYLE_TYPE, type, decl.important) if type
906
+ result << Declaration.new(PROP_LIST_STYLE_POSITION, position, decl.important) if position
907
+ result << Declaration.new(PROP_LIST_STYLE_IMAGE, image, decl.important) if image
800
908
 
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
909
+ result.empty? ? [decl] : result
832
910
  end
833
- end
834
911
 
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)
912
+ # Remove the individual longhand declarations a shorthand replaces and
913
+ # append the shorthand declaration in their place.
914
+ # Note: We append rather than insert at original position to match C implementation behavior
915
+ def replace_with_shorthand!(rule, longhand_props, shorthand_prop, value, important)
916
+ rule.declarations.reject! { |d| longhand_props.include?(d.property) }
917
+ rule.declarations << Declaration.new(shorthand_prop, value, important)
918
+ end
843
919
 
844
- result
845
- end
920
+ # Recreate shorthand properties where possible (mutates declarations)
921
+ #
922
+ # @param rule [Rule] Rule to recreate shorthands in
923
+ def recreate_shorthands!(rule)
924
+ # Build property map
925
+ prop_map = {}
926
+ rule.declarations.each { |d| prop_map[d.property] = d }
846
927
 
847
- # Check if value starts with 'url('
848
- def self.starts_with_url?(value)
849
- return false if value.bytesize < 4
928
+ # Try to recreate margin
929
+ recreate_margin!(rule, prop_map)
850
930
 
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
931
+ # Try to recreate padding
932
+ recreate_padding!(rule, prop_map)
856
933
 
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)
934
+ # Try to recreate border
935
+ recreate_border!(rule, prop_map)
861
936
 
862
- # Check for '%'
863
- i = 0
864
- len = value.bytesize
865
- while i < len
866
- return true if value.getbyte(i) == BYTE_PERCENT
937
+ # Try to recreate list-style
938
+ recreate_list_style!(rule, prop_map)
867
939
 
868
- i += 1
869
- end
870
- false
871
- end
940
+ # Try to recreate font
941
+ recreate_font!(rule, prop_map)
872
942
 
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
943
+ # Try to recreate background
944
+ recreate_background!(rule, prop_map)
894
945
  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
946
 
901
- result.empty? ? [decl] : result
902
- end
947
+ # Try to recreate margin shorthand
948
+ def recreate_margin!(rule, prop_map)
949
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
950
+ sides = []
951
+ MARGIN_SIDES.each { |s| sides << prop_map[s] }
952
+ return unless sides.all? # Need all four sides
953
+
954
+ # Check if all have same importance
955
+ # Use each + << instead of map
956
+ importances = []
957
+ sides.each { |s| importances << s.important }
958
+ importances.uniq!
959
+ return if importances.length > 1 # Mixed importance, can't create shorthand
960
+
961
+ # Use each + << instead of map
962
+ values = []
963
+ sides.each { |s| values << s.value }
964
+ important = sides.first.important
965
+
966
+ # Create optimized shorthand
967
+ shorthand_value = optimize_four_sides(values)
968
+
969
+ replace_with_shorthand!(rule, MARGIN_SIDES, PROP_MARGIN, shorthand_value, important)
970
+ end
903
971
 
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
972
+ # Try to recreate padding shorthand
973
+ def recreate_padding!(rule, prop_map)
974
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
975
+ sides = []
976
+ PADDING_SIDES.each { |s| sides << prop_map[s] }
977
+ return unless sides.all?
911
978
 
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 }
979
+ # Use each + << instead of map
980
+ importances = []
981
+ sides.each { |s| importances << s.important }
982
+ importances.uniq!
983
+ return if importances.length > 1
919
984
 
920
- # Try to recreate margin
921
- recreate_margin!(rule, prop_map)
985
+ # Use each + << instead of map
986
+ values = []
987
+ sides.each { |s| values << s.value }
988
+ important = sides.first.important
922
989
 
923
- # Try to recreate padding
924
- recreate_padding!(rule, prop_map)
990
+ shorthand_value = optimize_four_sides(values)
925
991
 
926
- # Try to recreate border
927
- recreate_border!(rule, prop_map)
992
+ replace_with_shorthand!(rule, PADDING_SIDES, PROP_PADDING, shorthand_value, important)
993
+ end
928
994
 
929
- # Try to recreate list-style
930
- recreate_list_style!(rule, prop_map)
995
+ # Helper: Check if all declarations have same value and importance
996
+ # Does single pass instead of multiple .map calls
997
+ def check_all_same?(decls)
998
+ return false if decls.empty?
931
999
 
932
- # Try to recreate font
933
- recreate_font!(rule, prop_map)
1000
+ first_val = decls[0].value
1001
+ first_imp = decls[0].important
934
1002
 
935
- # Try to recreate background
936
- recreate_background!(rule, prop_map)
937
- end
1003
+ i = 1
1004
+ while i < decls.length
1005
+ return false if decls[i].value != first_val
1006
+ return false if decls[i].important != first_imp
938
1007
 
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
1008
+ i += 1
1009
+ end
963
1010
 
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?
1011
+ true
1012
+ end
970
1013
 
971
- # Use each + << instead of map
972
- importances = []
973
- sides.each { |s| importances << s.important }
974
- importances.uniq!
975
- return if importances.length > 1
1014
+ # Try to recreate border shorthand
1015
+ def recreate_border!(rule, prop_map)
1016
+ # Check if we have all width/style/color properties with same values for all sides
1017
+ # Use each + << instead of map (1.05-1.20x faster, called once per rule during merge)
1018
+ widths = []
1019
+ BORDER_WIDTHS.each { |p| widths << prop_map[p] }
1020
+ styles = []
1021
+ BORDER_STYLES.each { |p| styles << prop_map[p] }
1022
+ colors = []
1023
+ BORDER_COLORS.each { |p| colors << prop_map[p] }
1024
+
1025
+ # Check if all sides have same values and can create full border shorthand
1026
+ # Check cheapest condition first (.all?), then do single pass for values/importance
1027
+ widths_all_same = widths.all? && check_all_same?(widths)
1028
+ styles_all_same = styles.all? && check_all_same?(styles)
1029
+ colors_all_same = colors.all? && check_all_same?(colors)
1030
+
1031
+ # Can create FULL border shorthand ONLY if style is present (style is required for border shorthand)
1032
+ # AND all properties that are present have same values and importance
1033
+ if styles_all_same
1034
+ # Check if we have width and/or color with same importance as style
1035
+ can_create_border = true
1036
+ important = styles.first.important
1037
+
1038
+ # If width is present, must be all-same and same importance
1039
+ if widths.all?
1040
+ can_create_border = false unless widths_all_same && widths.first.important == important
1041
+ end
976
1042
 
977
- # Use each + << instead of map
978
- values = []
979
- sides.each { |s| values << s.value }
980
- important = sides.first.important
1043
+ # If color is present, must be all-same and same importance
1044
+ if colors.all?
1045
+ can_create_border = false unless colors_all_same && colors.first.important == important
1046
+ end
981
1047
 
982
- shorthand_value = optimize_four_sides(values)
1048
+ if can_create_border
1049
+ # Create full border shorthand
1050
+ parts = []
1051
+ parts << widths.first.value if widths_all_same
1052
+ parts << styles.first.value
1053
+ parts << colors.first.value if colors_all_same
983
1054
 
984
- replace_with_shorthand!(rule, PADDING_SIDES, PROP_PADDING, shorthand_value, important)
985
- end
1055
+ border_value = parts.join(' ')
986
1056
 
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?
1057
+ replace_with_shorthand!(rule, BORDER_ALL, PROP_BORDER, border_value, important)
1058
+ return
1059
+ end
1060
+ end
991
1061
 
992
- first_val = decls[0].value
993
- first_imp = decls[0].important
1062
+ # Try to create border-width/style/color shorthands
1063
+ recreate_border_width!(rule, widths) if widths.all?
1064
+ recreate_border_style!(rule, styles) if styles.all?
1065
+ recreate_border_color!(rule, colors) if colors.all?
1066
+ end
994
1067
 
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
1068
+ # Recreate border-width shorthand
1069
+ def recreate_border_width!(rule, widths)
1070
+ importances = widths.map(&:important).uniq
1071
+ return if importances.length > 1
999
1072
 
1000
- i += 1
1001
- end
1073
+ values = widths.map(&:value)
1074
+ important = widths.first.important
1002
1075
 
1003
- true
1004
- end
1076
+ shorthand_value = optimize_four_sides(values)
1005
1077
 
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
1078
+ replace_with_shorthand!(rule, BORDER_WIDTHS, PROP_BORDER_WIDTH, shorthand_value, important)
1033
1079
  end
1034
1080
 
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
1081
+ # Recreate border-style shorthand
1082
+ def recreate_border_style!(rule, styles)
1083
+ importances = styles.map(&:important).uniq
1084
+ return if importances.length > 1
1039
1085
 
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
1086
+ values = styles.map(&:value)
1087
+ important = styles.first.important
1046
1088
 
1047
- border_value = parts.join(' ')
1089
+ shorthand_value = optimize_four_sides(values)
1048
1090
 
1049
- replace_with_shorthand!(rule, BORDER_ALL, PROP_BORDER, border_value, important)
1050
- return
1091
+ replace_with_shorthand!(rule, BORDER_STYLES, PROP_BORDER_STYLE, shorthand_value, important)
1051
1092
  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
1093
 
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
1094
+ # Recreate border-color shorthand
1095
+ def recreate_border_color!(rule, colors)
1096
+ importances = colors.map(&:important).uniq
1097
+ return if importances.length > 1
1064
1098
 
1065
- values = widths.map(&:value)
1066
- important = widths.first.important
1099
+ values = colors.map(&:value)
1100
+ important = colors.first.important
1067
1101
 
1068
- shorthand_value = optimize_four_sides(values)
1102
+ shorthand_value = optimize_four_sides(values)
1069
1103
 
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
1104
+ replace_with_shorthand!(rule, BORDER_COLORS, PROP_BORDER_COLOR, shorthand_value, important)
1105
+ end
1077
1106
 
1078
- values = styles.map(&:value)
1079
- important = styles.first.important
1107
+ # Optimize four-sided value representation
1108
+ # ["10px", "10px", "10px", "10px"] -> "10px"
1109
+ # ["10px", "20px", "10px", "20px"] -> "10px 20px"
1110
+ # ["10px", "20px", "30px", "20px"] -> "10px 20px 30px"
1111
+ # ["10px", "20px", "30px", "40px"] -> "10px 20px 30px 40px"
1112
+ def optimize_four_sides(values)
1113
+ top, right, bottom, left = values
1114
+
1115
+ if top == right && right == bottom && bottom == left
1116
+ top
1117
+ elsif top == bottom && right == left
1118
+ "#{top} #{right}"
1119
+ elsif right == left
1120
+ "#{top} #{right} #{bottom}"
1121
+ else
1122
+ "#{top} #{right} #{bottom} #{left}"
1123
+ end
1124
+ end
1080
1125
 
1081
- shorthand_value = optimize_four_sides(values)
1126
+ # Try to recreate font shorthand
1127
+ # Requires: font-size and font-family (minimum)
1128
+ # Optional: font-style, font-variant, font-weight, line-height
1129
+ def recreate_font!(rule, prop_map)
1130
+ size = prop_map[PROP_FONT_SIZE]
1131
+ family = prop_map[PROP_FONT_FAMILY]
1082
1132
 
1083
- replace_with_shorthand!(rule, BORDER_STYLES, PROP_BORDER_STYLE, shorthand_value, important)
1084
- end
1133
+ # Need at least size and family
1134
+ return unless size && family
1085
1135
 
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
1136
+ # Check if all font properties have same importance
1137
+ font_decls = FONT_PROPERTIES.filter_map { |p| prop_map[p] }
1138
+ return if font_decls.empty?
1090
1139
 
1091
- values = colors.map(&:value)
1092
- important = colors.first.important
1140
+ importances = font_decls.map(&:important).uniq
1141
+ return if importances.length > 1
1093
1142
 
1094
- shorthand_value = optimize_four_sides(values)
1143
+ important = font_decls.first.important
1095
1144
 
1096
- replace_with_shorthand!(rule, BORDER_COLORS, PROP_BORDER_COLOR, shorthand_value, important)
1097
- end
1145
+ # Build font shorthand value
1146
+ # Strategy: Only omit defaults if we have ALL 6 properties (from shorthand expansion)
1147
+ # If we have a partial set, include all non-nil values
1148
+ style = prop_map[PROP_FONT_STYLE]&.value
1149
+ variant = prop_map[PROP_FONT_VARIANT]&.value
1150
+ weight = prop_map[PROP_FONT_WEIGHT]&.value
1151
+ line_height = prop_map[PROP_LINE_HEIGHT]&.value
1098
1152
 
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
1153
+ all_present = style && variant && weight && line_height
1154
+ parts = []
1159
1155
 
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
1156
+ if all_present
1157
+ # All properties present (likely from shorthand expansion) - omit defaults
1158
+ parts << style if style != 'normal'
1159
+ parts << variant if variant != 'normal'
1160
+ parts << weight if weight != 'normal'
1161
+ else
1162
+ # Partial set - include all non-nil values
1163
+ parts << style if style
1164
+ parts << variant if variant
1165
+ parts << weight if weight
1166
+ end
1176
1167
 
1177
- # Required: family
1178
- parts << family.value
1168
+ # Required: size[/line-height]
1169
+ if all_present
1170
+ # Omit line-height if default
1171
+ if line_height != 'normal'
1172
+ parts << "#{size.value}/#{line_height}"
1173
+ else
1174
+ parts << size.value
1175
+ end
1176
+ else
1177
+ # Include line-height if present
1178
+ if line_height
1179
+ parts << "#{size.value}/#{line_height}"
1180
+ else
1181
+ parts << size.value
1182
+ end
1183
+ end
1179
1184
 
1180
- shorthand_value = parts.join(' ')
1185
+ # Required: family
1186
+ parts << family.value
1181
1187
 
1182
- replace_with_shorthand!(rule, FONT_PROPERTIES, PROP_FONT, shorthand_value, important)
1183
- end
1188
+ shorthand_value = parts.join(' ')
1184
1189
 
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
1190
+ replace_with_shorthand!(rule, FONT_PROPERTIES, PROP_FONT, shorthand_value, important)
1191
+ end
1229
1192
 
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
1193
+ # Try to recreate background shorthand
1194
+ # Can combine: background-color, background-image, background-repeat, etc.
1195
+ def recreate_background!(rule, prop_map)
1196
+ bg_props = BACKGROUND_PROPERTIES
1197
+ bg_decls = bg_props.filter_map { |p| prop_map[p] }
1198
+
1199
+ # Need at least 2 properties to create shorthand
1200
+ # Single properties should stay as longhands (e.g., background-color: blue)
1201
+ # because shorthand resets all other properties to initial values
1202
+ return if bg_decls.length < 2
1203
+
1204
+ # Check if all have same importance
1205
+ importances = bg_decls.map(&:important).uniq
1206
+ return if importances.length > 1
1207
+
1208
+ important = bg_decls.first.important
1209
+
1210
+ # Build background shorthand value
1211
+ # Strategy: Only omit defaults if we have ALL 5 properties (from shorthand expansion)
1212
+ # If we have a partial set (explicit longhands), include all values
1213
+ color = prop_map[PROP_BACKGROUND_COLOR]&.value
1214
+ image = prop_map[PROP_BACKGROUND_IMAGE]&.value
1215
+ repeat = prop_map[PROP_BACKGROUND_REPEAT]&.value
1216
+ position = prop_map[PROP_BACKGROUND_POSITION]&.value
1217
+ attachment = prop_map[PROP_BACKGROUND_ATTACHMENT]&.value
1218
+
1219
+ all_present = color && image && repeat && position && attachment
1220
+ parts = []
1237
1221
 
1238
- replace_with_shorthand!(rule, BACKGROUND_PROPERTIES, PROP_BACKGROUND, shorthand_value, important)
1239
- end
1222
+ if all_present
1223
+ # All 5 properties present (likely from shorthand expansion) - omit defaults
1224
+ parts << color if color != 'transparent'
1225
+ parts << image if image != 'none'
1226
+ parts << repeat if repeat != 'repeat'
1227
+ parts << position if position != '0% 0%'
1228
+ parts << attachment if attachment != 'scroll'
1229
+ else
1230
+ # Partial set (explicit longhands) - include all non-nil values
1231
+ parts << color if color
1232
+ parts << image if image
1233
+ parts << repeat if repeat
1234
+ parts << position if position
1235
+ parts << attachment if attachment
1236
+ end
1240
1237
 
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] }
1238
+ # If all properties are defaults, the shorthand value would be empty
1239
+ # In this case, use "none" which is equivalent to all-default background
1240
+ shorthand_value = if parts.empty?
1241
+ 'none'
1242
+ else
1243
+ parts.join(' ')
1244
+ end
1246
1245
 
1247
- # Need at least 2 properties to create shorthand
1248
- return if ls_decls.length < 2
1246
+ replace_with_shorthand!(rule, BACKGROUND_PROPERTIES, PROP_BACKGROUND, shorthand_value, important)
1247
+ end
1249
1248
 
1250
- # Check if all have same importance
1251
- importances = ls_decls.map(&:important).uniq
1252
- return if importances.length > 1
1249
+ # Try to recreate list-style shorthand
1250
+ # Can combine: list-style-type, list-style-position, list-style-image
1251
+ def recreate_list_style!(rule, prop_map)
1252
+ ls_props = LIST_STYLE_PROPERTIES
1253
+ ls_decls = ls_props.filter_map { |p| prop_map[p] }
1253
1254
 
1254
- important = ls_decls.first.important
1255
+ # Need at least 2 properties to create shorthand
1256
+ return if ls_decls.length < 2
1255
1257
 
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]
1258
+ # Check if all have same importance
1259
+ importances = ls_decls.map(&:important).uniq
1260
+ return if importances.length > 1
1261
1261
 
1262
- shorthand_value = parts.join(' ')
1262
+ important = ls_decls.first.important
1263
1263
 
1264
- replace_with_shorthand!(rule, LIST_STYLE_PROPERTIES, PROP_LIST_STYLE, shorthand_value, important)
1265
- end
1264
+ # Build list-style shorthand value
1265
+ parts = []
1266
+ parts << prop_map[PROP_LIST_STYLE_TYPE].value if prop_map[PROP_LIST_STYLE_TYPE]
1267
+ parts << prop_map[PROP_LIST_STYLE_POSITION].value if prop_map[PROP_LIST_STYLE_POSITION]
1268
+ parts << prop_map[PROP_LIST_STYLE_IMAGE].value if prop_map[PROP_LIST_STYLE_IMAGE]
1266
1269
 
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
1270
+ shorthand_value = parts.join(' ')
1285
1271
 
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
1272
+ replace_with_shorthand!(rule, LIST_STYLE_PROPERTIES, PROP_LIST_STYLE, shorthand_value, important)
1273
+ end
1290
1274
 
1291
- # Get first rule as reference
1292
- reference_rule = rules_in_list.first
1293
- reference_decls = reference_rule.declarations
1275
+ # Update selector lists to remove diverged rules
1276
+ #
1277
+ # After flattening/cascade, rules that were in the same selector list may have
1278
+ # different declarations. This method builds the selector_lists hash with only
1279
+ # rules that still match, and clears selector_list_id for diverged rules.
1280
+ #
1281
+ # @param merged_rules [Array<Rule>] Flattened rules (with new IDs assigned)
1282
+ # @param selector_lists [Hash] Empty hash to populate with list_id => Array of rule IDs
1283
+ def update_selector_lists_for_divergence!(merged_rules, selector_lists)
1284
+ # Group merged rules by selector_list_id (skip rules with no list)
1285
+ # Note: Using manual each loop instead of .select{}.group_by for performance.
1286
+ # The more concise form (.select + .group_by) is ~50-60% slower due to intermediate array allocation.
1287
+ rules_by_list = {}
1288
+ merged_rules.each do |r|
1289
+ next unless r.selector_list_id
1290
+
1291
+ (rules_by_list[r.selector_list_id] ||= []) << r
1292
+ end
1294
1293
 
1295
- # Find rules that still match (have identical declarations)
1296
- matching_rules = [reference_rule]
1294
+ # For each selector list, check if declarations still match
1295
+ rules_by_list.each do |list_id, rules_in_list|
1296
+ # Skip if only one rule in list (nothing to compare)
1297
+ next if rules_in_list.size <= 1
1298
+
1299
+ # Get first rule as reference
1300
+ reference_rule = rules_in_list.first
1301
+ reference_decls = reference_rule.declarations
1302
+
1303
+ # Find rules that still match (have identical declarations)
1304
+ matching_rules = [reference_rule]
1305
+
1306
+ rules_in_list[1..].each do |rule|
1307
+ if declarations_equal?(reference_decls, rule.declarations)
1308
+ matching_rules << rule
1309
+ else
1310
+ # Clear selector_list_id for diverged rule
1311
+ rule.selector_list_id = nil
1312
+ end
1313
+ end
1297
1314
 
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
1315
+ # Only keep the selector list if at least 2 rules still match
1316
+ if matching_rules.size >= 2
1317
+ # Build selector_lists hash with NEW rule IDs
1318
+ selector_lists[list_id] = matching_rules.map(&:id)
1319
+ else
1320
+ # Clear selector_list_id for the last remaining rule too
1321
+ matching_rules.each { |r| r.selector_list_id = nil }
1322
+ end
1304
1323
  end
1305
1324
  end
1306
1325
 
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 }
1326
+ # Check if two declaration arrays are identical
1327
+ #
1328
+ # @param decls1 [Array<Declaration>]
1329
+ # @param decls2 [Array<Declaration>]
1330
+ # @return [Boolean]
1331
+ def declarations_equal?(decls1, decls2)
1332
+ return false if decls1.size != decls2.size
1333
+
1334
+ # Compare each declaration (property, value, important must all match)
1335
+ decls1.zip(decls2).all? do |d1, d2|
1336
+ d1.property == d2.property &&
1337
+ d1.value == d2.value &&
1338
+ d1.important == d2.important
1339
+ end
1314
1340
  end
1315
- end
1316
- end
1317
1341
 
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
1342
+ # Mark all methods except flatten and expand_shorthand as private
1343
+ private :flatten_rules_for_selector, :calculate_specificity,
1344
+ :expand_margin, :expand_padding, :parse_four_sides, :split_on_whitespace,
1345
+ :expand_border, :expand_border_side, :expand_border_width, :expand_border_style,
1346
+ :expand_border_color, :parse_border_value, :contains_digit?, :is_border_width?,
1347
+ :is_border_style?,
1348
+ :expand_font, :is_font_size?, :is_font_style?, :is_font_variant?, :is_font_weight?,
1349
+ :expand_background, :starts_with_url?, :is_position_value?, :expand_list_style,
1350
+ :replace_with_shorthand!, :recreate_shorthands!, :recreate_margin!, :recreate_padding!,
1351
+ :check_all_same?,
1352
+ :recreate_border!, :recreate_border_width!, :recreate_border_style!, :recreate_border_color!,
1353
+ :optimize_four_sides, :recreate_font!, :recreate_background!, :recreate_list_style!,
1354
+ :update_selector_lists_for_divergence!, :declarations_equal?,
1355
+ :partition_at_rules, :expand_all_shorthands!, :merge_rules_by_selector_and_media,
1356
+ :build_selector_lists, :rebuild_media_index, :build_result
1331
1357
  end
1332
- end
1333
1358
 
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
1359
+ # A frozen singleton, same rationale as Pure itself (see pure.rb) - none of
1360
+ # FlattenImpl's methods touch instance state, so one shared instance is as
1361
+ # safe as the bare module this replaces.
1362
+ Flatten = FlattenImpl.new.freeze
1363
+ end
1349
1364
  end
1350
1365
  end