cataract 0.2.5 → 0.3.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.
@@ -99,7 +99,14 @@ module Cataract
99
99
  SIDE_RIGHT = 'right'
100
100
  SIDE_BOTTOM = 'bottom'
101
101
  SIDE_LEFT = 'left'
102
- BORDER_SIDES = [SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM, SIDE_LEFT].freeze
102
+
103
+ # Maps a border side name to its [width, style, color] property constants
104
+ BORDER_SIDE_PROPS = {
105
+ SIDE_TOP => [PROP_BORDER_TOP_WIDTH, PROP_BORDER_TOP_STYLE, PROP_BORDER_TOP_COLOR],
106
+ SIDE_RIGHT => [PROP_BORDER_RIGHT_WIDTH, PROP_BORDER_RIGHT_STYLE, PROP_BORDER_RIGHT_COLOR],
107
+ SIDE_BOTTOM => [PROP_BORDER_BOTTOM_WIDTH, PROP_BORDER_BOTTOM_STYLE, PROP_BORDER_BOTTOM_COLOR],
108
+ SIDE_LEFT => [PROP_BORDER_LEFT_WIDTH, PROP_BORDER_LEFT_STYLE, PROP_BORDER_LEFT_COLOR]
109
+ }.freeze
103
110
 
104
111
  FONT_PROPERTIES = [
105
112
  PROP_FONT_STYLE,
@@ -163,11 +170,38 @@ module Cataract
163
170
  # @param mutate [Boolean] If true, mutate the stylesheet; otherwise create new one
164
171
  # @return [Stylesheet] Merged stylesheet
165
172
  def self.flatten(stylesheet, mutate: false)
166
- # Separate AtRules (pass-through) from regular Rules (to merge)
173
+ at_rules, regular_rules = partition_at_rules(stylesheet.rules)
174
+
175
+ expand_all_shorthands!(regular_rules)
176
+ merged_rules = merge_rules_by_selector_and_media(regular_rules)
177
+ merged_rules.each { |rule| recreate_shorthands!(rule) }
178
+
179
+ # Assign new IDs before checking divergence (so we can build correct selector_lists hash)
180
+ merged_rules.each_with_index { |rule, i| rule.id = i }
181
+
182
+ selector_lists = build_selector_lists(stylesheet, merged_rules)
183
+
184
+ # Add passthrough AtRules to output
185
+ merged_rules.concat(at_rules)
186
+
187
+ media_queries = stylesheet.instance_variable_get(:@media_queries)
188
+ media_query_lists = stylesheet.instance_variable_get(:@_media_query_lists)
189
+ new_media_index = rebuild_media_index(merged_rules, media_queries, media_query_lists)
190
+
191
+ build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists, selector_lists,
192
+ mutate)
193
+ end
194
+
195
+ # Separate AtRules (pass-through, unaffected by cascade) from regular
196
+ # Rules (to merge).
197
+ #
198
+ # @param rules [Array<Rule, AtRule>]
199
+ # @return [Array(Array<AtRule>, Array<Rule>)]
200
+ def self.partition_at_rules(rules)
167
201
  at_rules = []
168
202
  regular_rules = []
169
203
 
170
- stylesheet.rules.each do |rule|
204
+ rules.each do |rule|
171
205
  if rule.at_rule?
172
206
  at_rules << rule
173
207
  else
@@ -175,11 +209,20 @@ module Cataract
175
209
  end
176
210
  end
177
211
 
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).
212
+ [at_rules, regular_rules]
213
+ end
214
+
215
+ # Expand shorthands in regular rules only (AtRules don't have declarations).
216
+ # Mutates each rule's declarations in place.
217
+ #
218
+ # NOTE: Using manual each + concat instead of .flat_map for performance.
219
+ # The concise form (.flat_map) is ~5-10% slower depending on number of shorthands to expand.
220
+ # NOTE: Fast-path check for shorthands (Hash lookup) avoids calling expand_shorthand
221
+ # for declarations that are not shorthands (~20% faster than calling method unconditionally).
222
+ #
223
+ # @param regular_rules [Array<Rule>]
224
+ # @return [void]
225
+ def self.expand_all_shorthands!(regular_rules)
183
226
  regular_rules.each do |rule|
184
227
  expanded = []
185
228
  rule.declarations.each do |decl|
@@ -191,58 +234,55 @@ module Cataract
191
234
  end
192
235
  rule.declarations.replace(expanded)
193
236
  end
237
+ end
194
238
 
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
239
+ # Group rules by (selector, media_query_id) and merge each group
240
+ # according to CSS cascade rules. Rules with the same selector but
241
+ # different media contexts are NOT merged together.
242
+ #
243
+ # @param regular_rules [Array<Rule>]
244
+ # @return [Array<Rule>] Merged rules, one per (selector, media_query_id) group
245
+ def self.merge_rules_by_selector_and_media(regular_rules)
199
246
  # NOTE: Using manual each instead of .group_by to avoid intermediate hash allocation.
200
247
  by_selector_and_media = {}
201
248
  regular_rules.each do |rule|
202
- media_query_id = rule.media_query_id
203
- key = [rule.selector, media_query_id]
249
+ key = [rule.selector, rule.media_query_id]
204
250
  (by_selector_and_media[key] ||= []) << rule
205
251
  end
206
252
 
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|
253
+ merged_rules = []
254
+ by_selector_and_media.each_value do |rules|
210
255
  merged_rule = flatten_rules_for_selector(rules.first.selector, rules)
211
- next unless merged_rule
212
-
213
- # Only build mapping for rules that are in media queries
214
- if media_query_id
215
- new_index = merged_rules.length
216
-
217
- rules.each do |old_rule|
218
- old_to_new_id[old_rule.id] = new_index
219
- end
220
- end
221
- merged_rules << merged_rule
256
+ merged_rules << merged_rule if merged_rule
222
257
  end
258
+ merged_rules
259
+ end
223
260
 
224
- # Recreate shorthands where possible
225
- merged_rules.each { |rule| recreate_shorthands!(rule) }
226
-
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 }
229
-
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
261
+ # Handle selector list divergence: remove rules from selector lists if
262
+ # declarations no longer match. This makes selector_list_id
263
+ # authoritative - if set, declarations MUST be identical. Only
264
+ # processed if selector_lists is enabled in the stylesheet's parser
265
+ # options.
266
+ #
267
+ # @param stylesheet [Stylesheet] Source stylesheet (for parser options)
268
+ # @param merged_rules [Array<Rule>] Merged rules, already assigned final IDs
269
+ # @return [Hash] list_id => Array of rule IDs, empty if not tracking selector lists
270
+ def self.build_selector_lists(stylesheet, merged_rules)
233
271
  selector_lists = {}
234
272
  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
238
-
239
- # Add passthrough AtRules to output
240
- merged_rules.concat(at_rules)
273
+ update_selector_lists_for_divergence!(merged_rules, selector_lists) if parser_options[:selector_lists]
274
+ selector_lists
275
+ end
241
276
 
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)
277
+ # Rebuild media_index from rules' media_query_id. This ensures
278
+ # media_index is consistent with the MediaQuery objects, rather than
279
+ # trying to remap the stylesheet's existing (now-stale) media_index.
280
+ #
281
+ # @param merged_rules [Array<Rule, AtRule>] Final rules, including passthrough AtRules
282
+ # @param media_queries [Array<MediaQuery>]
283
+ # @param media_query_lists [Hash{Integer => Array<Integer>}] list_id => media_query_ids
284
+ # @return [Hash{Symbol => Array<Integer>}] media type => rule IDs
285
+ def self.rebuild_media_index(merged_rules, media_queries, media_query_lists)
246
286
  new_media_index = {}
247
287
 
248
288
  # Build reverse map: media_query_id => list_id (one-time cost)
@@ -265,34 +305,37 @@ module Cataract
265
305
  mq = media_queries[mq_id]
266
306
  next unless mq
267
307
 
268
- media_type = mq.type
269
- new_media_index[media_type] ||= []
270
- new_media_index[media_type] << rule.id
308
+ new_media_index[mq.type] ||= []
309
+ new_media_index[mq.type] << rule.id
271
310
  end
272
311
  else
273
312
  # Single media query - just index under its type
274
313
  mq = media_queries[rule.media_query_id]
275
314
  next unless mq
276
315
 
277
- media_type = mq.type
278
- new_media_index[media_type] ||= []
279
- new_media_index[media_type] << rule.id
316
+ new_media_index[mq.type] ||= []
317
+ new_media_index[mq.type] << rule.id
280
318
  end
281
319
  end
282
320
 
283
321
  # Deduplicate arrays once at the end
284
322
  new_media_index.each_value(&:uniq!)
323
+ new_media_index
324
+ end
285
325
 
286
- # Create result stylesheet
326
+ # Build the final result: either mutate the input stylesheet in place,
327
+ # or construct a new one, with the merged rules/media_index/selector_lists.
328
+ #
329
+ # @return [Stylesheet]
330
+ def self.build_result(stylesheet, merged_rules, new_media_index, media_queries, media_query_lists,
331
+ selector_lists, mutate)
287
332
  if mutate
288
333
  stylesheet.instance_variable_set(:@rules, merged_rules)
289
334
  stylesheet.instance_variable_set(:@media_index, new_media_index)
290
335
  # @media_queries and @_media_query_lists stay the same - preserved from input
291
- # Update selector lists with divergence tracking
292
336
  stylesheet.instance_variable_set(:@_selector_lists, selector_lists)
293
337
  stylesheet
294
338
  else
295
- # Create new Stylesheet with merged rules
296
339
  result = Stylesheet.new
297
340
  result.instance_variable_set(:@rules, merged_rules)
298
341
  result.instance_variable_set(:@media_index, new_media_index)
@@ -553,39 +596,12 @@ module Cataract
553
596
  # Extract side from property name (e.g., "border-top" -> "top")
554
597
  side = decl.property.byteslice(7..-1) # Skip "border-" prefix
555
598
  width, style, color = parse_border_value(decl.value)
599
+ width_prop, style_prop, color_prop = BORDER_SIDE_PROPS[side]
556
600
 
557
601
  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
569
-
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
579
-
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
602
+ result << Declaration.new(width_prop, width, decl.important) if width
603
+ result << Declaration.new(style_prop, style, decl.important) if style
604
+ result << Declaration.new(color_prop, color, decl.important) if color
589
605
 
590
606
  result
591
607
  end
@@ -644,12 +660,8 @@ module Cataract
644
660
  [width, style, color]
645
661
  end
646
662
 
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)
651
-
652
- # Check if value contains a digit (byte-by-byte)
663
+ # Check if value contains a digit (byte-by-byte)
664
+ def self.contains_digit?(value)
653
665
  i = 0
654
666
  len = value.bytesize
655
667
  while i < len
@@ -662,6 +674,14 @@ module Cataract
662
674
  false
663
675
  end
664
676
 
677
+ # Check if value looks like a border width
678
+ def self.is_border_width?(value)
679
+ # Check for numeric values or width keywords
680
+ return true if BORDER_WIDTH_KEYWORDS.include?(value)
681
+
682
+ contains_digit?(value)
683
+ end
684
+
665
685
  # Check if value is a border style
666
686
  def self.is_border_style?(value)
667
687
  BORDER_STYLE_KEYWORDS.include?(value)
@@ -702,7 +722,7 @@ module Cataract
702
722
  j = 0
703
723
  len = size_part.bytesize
704
724
  while j < len
705
- if size_part.getbyte(j) == BYTE_SLASH_FWD
725
+ if size_part.getbyte(j) == BYTE_SLASH
706
726
  slash_idx = j
707
727
  break
708
728
  end
@@ -755,14 +775,8 @@ module Cataract
755
775
  # Check if value is a font size
756
776
  def self.is_font_size?(value)
757
777
  # 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
778
+ return true if contains_digit?(value)
763
779
 
764
- i += 1
765
- end
766
780
  FONT_SIZE_KEYWORDS.include?(value)
767
781
  end
768
782
 
@@ -779,14 +793,8 @@ module Cataract
779
793
  # Check if value is a font weight
780
794
  def self.is_font_weight?(value)
781
795
  # 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
796
+ return true if contains_digit?(value)
787
797
 
788
- i += 1
789
- end
790
798
  FONT_WEIGHT_KEYWORDS.include?(value)
791
799
  end
792
800
 
@@ -849,14 +857,13 @@ module Cataract
849
857
  # Check if value is a position value (for background-position)
850
858
  def self.is_position_value?(value)
851
859
  return true if BACKGROUND_POSITION_KEYWORDS.include?(value)
860
+ return true if contains_digit?(value)
852
861
 
853
- # Check for '%' or digits
862
+ # Check for '%'
854
863
  i = 0
855
864
  len = value.bytesize
856
865
  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
866
+ return true if value.getbyte(i) == BYTE_PERCENT
860
867
 
861
868
  i += 1
862
869
  end
@@ -894,6 +901,14 @@ module Cataract
894
901
  result.empty? ? [decl] : result
895
902
  end
896
903
 
904
+ # Remove the individual longhand declarations a shorthand replaces and
905
+ # append the shorthand declaration in their place.
906
+ # Note: We append rather than insert at original position to match C implementation behavior
907
+ def self.replace_with_shorthand!(rule, longhand_props, shorthand_prop, value, important)
908
+ rule.declarations.reject! { |d| longhand_props.include?(d.property) }
909
+ rule.declarations << Declaration.new(shorthand_prop, value, important)
910
+ end
911
+
897
912
  # Recreate shorthand properties where possible (mutates declarations)
898
913
  #
899
914
  # @param rule [Rule] Rule to recreate shorthands in
@@ -943,10 +958,7 @@ module Cataract
943
958
  # Create optimized shorthand
944
959
  shorthand_value = optimize_four_sides(values)
945
960
 
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)
961
+ replace_with_shorthand!(rule, MARGIN_SIDES, PROP_MARGIN, shorthand_value, important)
950
962
  end
951
963
 
952
964
  # Try to recreate padding shorthand
@@ -969,10 +981,7 @@ module Cataract
969
981
 
970
982
  shorthand_value = optimize_four_sides(values)
971
983
 
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)
984
+ replace_with_shorthand!(rule, PADDING_SIDES, PROP_PADDING, shorthand_value, important)
976
985
  end
977
986
 
978
987
  # Helper: Check if all declarations have same value and importance
@@ -1037,10 +1046,7 @@ module Cataract
1037
1046
 
1038
1047
  border_value = parts.join(' ')
1039
1048
 
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)
1049
+ replace_with_shorthand!(rule, BORDER_ALL, PROP_BORDER, border_value, important)
1044
1050
  return
1045
1051
  end
1046
1052
  end
@@ -1061,8 +1067,7 @@ module Cataract
1061
1067
 
1062
1068
  shorthand_value = optimize_four_sides(values)
1063
1069
 
1064
- rule.declarations.reject! { |d| BORDER_WIDTHS.include?(d.property) }
1065
- rule.declarations << Declaration.new(PROP_BORDER_WIDTH, shorthand_value, important)
1070
+ replace_with_shorthand!(rule, BORDER_WIDTHS, PROP_BORDER_WIDTH, shorthand_value, important)
1066
1071
  end
1067
1072
 
1068
1073
  # Recreate border-style shorthand
@@ -1075,8 +1080,7 @@ module Cataract
1075
1080
 
1076
1081
  shorthand_value = optimize_four_sides(values)
1077
1082
 
1078
- rule.declarations.reject! { |d| BORDER_STYLES.include?(d.property) }
1079
- rule.declarations << Declaration.new(PROP_BORDER_STYLE, shorthand_value, important)
1083
+ replace_with_shorthand!(rule, BORDER_STYLES, PROP_BORDER_STYLE, shorthand_value, important)
1080
1084
  end
1081
1085
 
1082
1086
  # Recreate border-color shorthand
@@ -1089,8 +1093,7 @@ module Cataract
1089
1093
 
1090
1094
  shorthand_value = optimize_four_sides(values)
1091
1095
 
1092
- rule.declarations.reject! { |d| BORDER_COLORS.include?(d.property) }
1093
- rule.declarations << Declaration.new(PROP_BORDER_COLOR, shorthand_value, important)
1096
+ replace_with_shorthand!(rule, BORDER_COLORS, PROP_BORDER_COLOR, shorthand_value, important)
1094
1097
  end
1095
1098
 
1096
1099
  # Optimize four-sided value representation
@@ -1176,10 +1179,7 @@ module Cataract
1176
1179
 
1177
1180
  shorthand_value = parts.join(' ')
1178
1181
 
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)
1182
+ replace_with_shorthand!(rule, FONT_PROPERTIES, PROP_FONT, shorthand_value, important)
1183
1183
  end
1184
1184
 
1185
1185
  # Try to recreate background shorthand
@@ -1235,10 +1235,7 @@ module Cataract
1235
1235
  parts.join(' ')
1236
1236
  end
1237
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)
1238
+ replace_with_shorthand!(rule, BACKGROUND_PROPERTIES, PROP_BACKGROUND, shorthand_value, important)
1242
1239
  end
1243
1240
 
1244
1241
  # Try to recreate list-style shorthand
@@ -1264,10 +1261,7 @@ module Cataract
1264
1261
 
1265
1262
  shorthand_value = parts.join(' ')
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)
1264
+ replace_with_shorthand!(rule, LIST_STYLE_PROPERTIES, PROP_LIST_STYLE, shorthand_value, important)
1271
1265
  end
1272
1266
 
1273
1267
  # Update selector lists to remove diverged rules
@@ -1341,12 +1335,16 @@ module Cataract
1341
1335
  private_class_method :flatten_rules_for_selector, :calculate_specificity,
1342
1336
  :expand_margin, :expand_padding, :parse_four_sides, :split_on_whitespace,
1343
1337
  :expand_border, :expand_border_side, :expand_border_width, :expand_border_style,
1344
- :expand_border_color, :parse_border_value, :is_border_width?, :is_border_style?,
1338
+ :expand_border_color, :parse_border_value, :contains_digit?, :is_border_width?,
1339
+ :is_border_style?,
1345
1340
  :expand_font, :is_font_size?, :is_font_style?, :is_font_variant?, :is_font_weight?,
1346
1341
  :expand_background, :starts_with_url?, :is_position_value?, :expand_list_style,
1347
- :recreate_shorthands!, :recreate_margin!, :recreate_padding!, :check_all_same?,
1342
+ :replace_with_shorthand!, :recreate_shorthands!, :recreate_margin!, :recreate_padding!,
1343
+ :check_all_same?,
1348
1344
  :recreate_border!, :recreate_border_width!, :recreate_border_style!, :recreate_border_color!,
1349
1345
  :optimize_four_sides, :recreate_font!, :recreate_background!, :recreate_list_style!,
1350
- :update_selector_lists_for_divergence!, :declarations_equal?
1346
+ :update_selector_lists_for_divergence!, :declarations_equal?,
1347
+ :partition_at_rules, :expand_all_shorthands!, :merge_rules_by_selector_and_media,
1348
+ :build_selector_lists, :rebuild_media_index, :build_result
1351
1349
  end
1352
1350
  end