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