cataract 0.2.2 → 0.2.4

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.
@@ -15,31 +15,55 @@ module Cataract
15
15
  #
16
16
  # @example Import with media query
17
17
  # @import "mobile.css" screen and (max-width: 768px);
18
- # # => ImportStatement(url: "mobile.css", media: :"screen and (max-width: 768px)")
18
+ # # => ImportStatement(url: "mobile.css", media_query_id: 0)
19
19
  #
20
20
  # @attr [Integer] id The import's position in the source (0-indexed)
21
21
  # @attr [String] url The URL to import (without quotes or url() wrapper)
22
- # @attr [Symbol, nil] media The media query as a symbol, or nil if no media query
22
+ # @attr [String, nil] media The media query string (e.g., "print", "screen and (max-width: 768px)"), or nil
23
+ # @attr [Integer, nil] media_query_id The MediaQuery ID, or nil if no media query
23
24
  # @attr [Boolean] resolved Whether this import has been resolved/processed
24
- ImportStatement = Struct.new(:id, :url, :media, :resolved) unless const_defined?(:ImportStatement)
25
+ ImportStatement = Struct.new(:id, :url, :media, :media_query_id, :resolved) unless const_defined?(:ImportStatement)
25
26
 
26
27
  class ImportStatement
28
+ # Factory method for creating ImportStatement in tests.
29
+ # Uses keyword arguments to avoid positional parameter confusion.
30
+ #
31
+ # @param id [Integer] Import ID (position in source)
32
+ # @param url [String] URL to import
33
+ # @param media [String, nil] Media query string (e.g., "print", "screen and (max-width: 768px)")
34
+ # @param media_query_id [Integer, nil] MediaQuery ID
35
+ # @param resolved [Boolean] Whether import has been resolved
36
+ # @return [ImportStatement] New import statement instance
37
+ #
38
+ # @example Create an import with keyword arguments
39
+ # ImportStatement.make(
40
+ # id: 0,
41
+ # url: 'styles.css',
42
+ # media: nil,
43
+ # media_query_id: nil,
44
+ # resolved: false
45
+ # )
46
+ def self.make(id:, url:, media: nil, media_query_id: nil, resolved: false)
47
+ new(id, url, media, media_query_id, resolved)
48
+ end
49
+
27
50
  # Compare two ImportStatement objects for equality.
28
51
  # Two imports are equal if they have the same URL and media query.
29
- # The ID is ignored as it's an implementation detail.
52
+ # The import ID is ignored as it's an implementation detail.
30
53
  #
31
54
  # @param other [Object] Object to compare with
32
55
  # @return [Boolean] true if equal, false otherwise
33
56
  def ==(other)
34
57
  return false unless other.is_a?(ImportStatement)
35
58
 
59
+ # Compare by media string (for unparsed imports) or media_query_id (for resolved imports)
36
60
  url == other.url && media == other.media
37
61
  end
38
62
 
39
63
  alias eql? ==
40
64
 
41
65
  # Generate hash code for ImportStatement.
42
- # Uses URL and media query (ignores ID).
66
+ # Uses URL and media string (ignores import ID position).
43
67
  #
44
68
  # @return [Integer] Hash code
45
69
  def hash
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # MediaQuery represents a CSS media query constraint.
5
+ #
6
+ # Media queries are stored in the Stylesheet and referenced by Rules via media_query_id.
7
+ # This allows efficient tracking of which rules apply to which media contexts.
8
+ #
9
+ # @example Access media query properties
10
+ # mq = MediaQuery.new(0, :screen, "(min-width: 768px)")
11
+ # mq.id #=> 0
12
+ # mq.type #=> :screen
13
+ # mq.conditions #=> "(min-width: 768px)"
14
+ # mq.text #=> "screen and (min-width: 768px)"
15
+ #
16
+ # @attr [Integer] id Unique identifier for this media query within the stylesheet
17
+ # @attr [Symbol] type Primary media type (:screen, :print, :all, etc.)
18
+ # @attr [String, nil] conditions Additional conditions like "(min-width: 768px)", or nil if none
19
+ MediaQuery = Struct.new(:id, :type, :conditions) do
20
+ # Create a MediaQuery with keyword arguments for readability.
21
+ #
22
+ # @param id [Integer] Unique ID for this media query
23
+ # @param type [Symbol] Primary media type
24
+ # @param conditions [String, nil] Optional conditions
25
+ # @return [MediaQuery] New media query instance
26
+ #
27
+ # @example Create a simple media query
28
+ # MediaQuery.make(id: 0, type: :screen, conditions: nil)
29
+ #
30
+ # @example Create a media query with conditions
31
+ # MediaQuery.make(id: 1, type: :screen, conditions: "(min-width: 768px)")
32
+ def self.make(id:, type:, conditions: nil)
33
+ new(id, type, conditions)
34
+ end
35
+
36
+ # Get the full media query text.
37
+ #
38
+ # Reconstructs the media query string from type and conditions.
39
+ #
40
+ # @return [String] Full media query text
41
+ #
42
+ # @example Simple media query
43
+ # mq = MediaQuery.new(0, :screen, nil)
44
+ # mq.text #=> "screen"
45
+ #
46
+ # @example Media query with conditions
47
+ # mq = MediaQuery.new(0, :screen, "(min-width: 768px)")
48
+ # mq.text #=> "screen and (min-width: 768px)"
49
+ def text
50
+ if conditions
51
+ # If type is :all, just return conditions (don't say "all and ...")
52
+ type == :all ? conditions : "#{type} and #{conditions}"
53
+ else
54
+ type.to_s
55
+ end
56
+ end
57
+
58
+ # Compare media queries for equality based on type and conditions.
59
+ #
60
+ # Two media queries are equal if they have the same type and conditions.
61
+ # IDs are not considered since they're internal identifiers.
62
+ #
63
+ # @param other [Object] Object to compare with
64
+ # @return [Boolean] true if media queries match
65
+ def ==(other)
66
+ case other
67
+ when MediaQuery
68
+ type == other.type && conditions == other.conditions
69
+ else
70
+ false
71
+ end
72
+ end
73
+ alias_method :eql?, :==
74
+
75
+ # Generate hash code for this media query.
76
+ #
77
+ # Hash is based on type and conditions to match equality semantics.
78
+ #
79
+ # @return [Integer] hash code
80
+ def hash
81
+ [self.class, type, conditions].hash
82
+ end
83
+
84
+ # Get a human-readable representation.
85
+ #
86
+ # @return [String] String representation
87
+ def to_s
88
+ text
89
+ end
90
+
91
+ # Get detailed inspection string.
92
+ #
93
+ # @return [String] Inspection string
94
+ def inspect
95
+ "#<MediaQuery id=#{id} type=#{type.inspect} conditions=#{conditions.inspect}>"
96
+ end
97
+ end
98
+ end
@@ -51,6 +51,17 @@ module Cataract
51
51
  BYTE_LOWER_U = 117 # 'u'
52
52
  BYTE_LOWER_R = 114 # 'r'
53
53
  BYTE_LOWER_L = 108 # 'l'
54
+ BYTE_LOWER_D = 100 # 'd'
55
+ BYTE_LOWER_T = 116 # 't'
56
+ BYTE_LOWER_N = 110 # 'n'
57
+
58
+ # Specific uppercase letters (for case-insensitive matching)
59
+ BYTE_UPPER_U = 85 # 'U'
60
+ BYTE_UPPER_R = 82 # 'R'
61
+ BYTE_UPPER_L = 76 # 'L'
62
+ BYTE_UPPER_D = 68 # 'D'
63
+ BYTE_UPPER_T = 84 # 'T'
64
+ BYTE_UPPER_N = 78 # 'N'
54
65
 
55
66
  # Letter ranges (a-z, A-Z)
56
67
  BYTE_LOWER_A = 97 # 'a'
@@ -2,6 +2,12 @@
2
2
 
3
3
  # Pure Ruby CSS flatten implementation
4
4
  # NO REGEXP ALLOWED - use string manipulation only
5
+ #
6
+ # @api private
7
+ # This module contains internal methods for flattening CSS (merging rules with the
8
+ # same selector, expanding/recreating shorthands). These methods are called by
9
+ # Cataract.flatten and should not be used directly except for expand_shorthand
10
+ # which is part of the public API. The main public API is Cataract.flatten.
5
11
 
6
12
  module Cataract
7
13
  module Flatten
@@ -115,6 +121,24 @@ module Cataract
115
121
  LIST_STYLE_PROPERTIES = [PROP_LIST_STYLE_TYPE, PROP_LIST_STYLE_POSITION, PROP_LIST_STYLE_IMAGE].freeze
116
122
  BORDER_ALL = (BORDER_WIDTHS + BORDER_STYLES + BORDER_COLORS).freeze
117
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
+
118
142
  # List style keywords
119
143
  LIST_STYLE_POSITION_KEYWORDS = %w[inside outside].freeze
120
144
 
@@ -154,27 +178,47 @@ module Cataract
154
178
  # Expand shorthands in regular rules only (AtRules don't have declarations)
155
179
  # NOTE: Using manual each + concat instead of .flat_map for performance.
156
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).
157
183
  regular_rules.each do |rule|
158
184
  expanded = []
159
185
  rule.declarations.each do |decl|
160
- expanded.concat(_expand_shorthand(decl))
186
+ if SHORTHAND_PROPERTIES[decl.property]
187
+ expanded.concat(expand_shorthand(decl))
188
+ else
189
+ expanded << decl
190
+ end
161
191
  end
162
192
  rule.declarations.replace(expanded)
163
193
  end
164
194
 
165
195
  merged_rules = []
166
196
 
167
- # Always group by selector and preserve original selectors
168
- # (Nesting is flattened during parsing, so we just merge by resolved selector)
197
+ # Group by (selector, media_query_id) instead of just selector
198
+ # Rules with same selector but different media contexts should NOT be merged
169
199
  # NOTE: Using manual each instead of .group_by to avoid intermediate hash allocation.
170
- # The concise form (.group_by) is ~10-25% slower depending on selector uniqueness.
171
- by_selector = {}
200
+ by_selector_and_media = {}
172
201
  regular_rules.each do |rule|
173
- (by_selector[rule.selector] ||= []) << rule
202
+ media_query_id = rule.media_query_id
203
+ key = [rule.selector, media_query_id]
204
+ (by_selector_and_media[key] ||= []) << rule
174
205
  end
175
- by_selector.each do |selector, rules|
176
- merged_rule = flatten_rules_for_selector(selector, rules)
177
- merged_rules << merged_rule if merged_rule
206
+
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
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
178
222
  end
179
223
 
180
224
  # Recreate shorthands where possible
@@ -195,11 +239,55 @@ module Cataract
195
239
  # Add passthrough AtRules to output
196
240
  merged_rules.concat(at_rules)
197
241
 
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 = {}
247
+
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
253
+
254
+ merged_rules.each do |rule|
255
+ next unless rule.is_a?(Rule) && rule.media_query_id
256
+
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]
259
+
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
267
+
268
+ media_type = mq.type
269
+ new_media_index[media_type] ||= []
270
+ new_media_index[media_type] << rule.id
271
+ 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
+ end
281
+ end
282
+
283
+ # Deduplicate arrays once at the end
284
+ new_media_index.each_value(&:uniq!)
285
+
198
286
  # Create result stylesheet
199
287
  if mutate
200
288
  stylesheet.instance_variable_set(:@rules, merged_rules)
201
- # Clear media index (no media rules after merge flattens everything)
202
- stylesheet.instance_variable_set(:@media_index, {})
289
+ stylesheet.instance_variable_set(:@media_index, new_media_index)
290
+ # @media_queries and @_media_query_lists stay the same - preserved from input
203
291
  # Update selector lists with divergence tracking
204
292
  stylesheet.instance_variable_set(:@_selector_lists, selector_lists)
205
293
  stylesheet
@@ -207,7 +295,9 @@ module Cataract
207
295
  # Create new Stylesheet with merged rules
208
296
  result = Stylesheet.new
209
297
  result.instance_variable_set(:@rules, merged_rules)
210
- result.instance_variable_set(:@media_index, {})
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)
211
301
  result.instance_variable_set(:@charset, stylesheet.charset)
212
302
  result.instance_variable_set(:@_selector_lists, selector_lists)
213
303
  result
@@ -294,6 +384,9 @@ module Cataract
294
384
  selector_list_ids.uniq!
295
385
  selector_list_id = selector_list_ids.size == 1 ? selector_list_ids.first : nil
296
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
+
297
390
  # Create merged rule
298
391
  Rule.new(
299
392
  0, # ID will be updated later
@@ -302,7 +395,8 @@ module Cataract
302
395
  rules.first.specificity, # Use first rule's specificity
303
396
  nil, # No parent after flattening
304
397
  nil, # No nesting style after flattening
305
- selector_list_id # Preserve if all rules share same ID
398
+ selector_list_id, # Preserve if all rules share same ID
399
+ media_query_id # Preserve media context
306
400
  )
307
401
  end
308
402
 
@@ -321,7 +415,7 @@ module Cataract
321
415
  # @param decl [Declaration] Declaration to expand
322
416
  # @return [Array<Declaration>] Array of expanded longhand declarations
323
417
  # @api private
324
- def self._expand_shorthand(decl)
418
+ def self.expand_shorthand(decl)
325
419
  case decl.property
326
420
  when 'margin'
327
421
  expand_margin(decl)
@@ -1133,7 +1227,13 @@ module Cataract
1133
1227
  parts << attachment if attachment
1134
1228
  end
1135
1229
 
1136
- shorthand_value = parts.join(' ')
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
1137
1237
 
1138
1238
  # Remove individual properties and append shorthand
1139
1239
  # Note: We append rather than insert at original position to match C implementation behavior
@@ -1236,5 +1336,17 @@ module Cataract
1236
1336
  d1.important == d2.important
1237
1337
  end
1238
1338
  end
1339
+
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?
1239
1351
  end
1240
1352
  end