cataract 0.2.3 → 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.
@@ -2,17 +2,52 @@
2
2
 
3
3
  # Pure Ruby CSS parser - Serialization methods
4
4
  # NO REGEXP ALLOWED - char-by-char parsing only
5
+ #
6
+ # @api private
7
+ # This module contains internal serialization methods for converting parsed CSS
8
+ # back to strings. These methods are called by Stylesheet#to_s and should not be
9
+ # used directly. The public API is through the Stylesheet class.
5
10
 
6
11
  module Cataract
12
+ # @api private
13
+ # Helper: Build media query string from MediaQuery object or list
14
+ # @param media_query [MediaQuery] The MediaQuery object
15
+ # @param media_query_list_id [Integer, nil] Optional list ID if this is part of a comma-separated list
16
+ # @param media_query_lists [Hash] Hash mapping list_id => array of MediaQuery IDs
17
+ # @param media_queries [Array] Array of all MediaQuery objects
18
+ # @return [String] Serialized media query string (e.g., "screen", "screen, print", "screen and (min-width: 768px)")
19
+ def self._build_media_query_string(media_query, media_query_list_id, media_query_lists, media_queries)
20
+ if media_query_list_id
21
+ # Comma-separated list
22
+ mq_ids = media_query_lists[media_query_list_id]
23
+ mq_ids.map do |mq_id|
24
+ mq = media_queries[mq_id]
25
+ if mq.conditions
26
+ mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
27
+ else
28
+ mq.type.to_s
29
+ end
30
+ end.join(', ')
31
+ else
32
+ # Single query
33
+ if media_query.conditions
34
+ media_query.type == :all ? media_query.conditions : "#{media_query.type} and #{media_query.conditions}"
35
+ else
36
+ media_query.type.to_s
37
+ end
38
+ end
39
+ end
40
+
7
41
  # Serialize stylesheet to compact CSS string
8
42
  #
9
43
  # @param rules [Array<Rule>] Array of rules
10
- # @param media_index [Hash] Media query symbol => array of rule IDs
11
44
  # @param charset [String, nil] @charset value
12
45
  # @param has_nesting [Boolean] Whether any nested rules exist
13
46
  # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
47
+ # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
48
+ # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
14
49
  # @return [String] Compact CSS string
15
- def self._stylesheet_to_s(rules, media_index, charset, has_nesting, selector_lists = {})
50
+ def self.stylesheet_to_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
16
51
  result = +''
17
52
 
18
53
  # Add @charset if present
@@ -22,7 +57,7 @@ module Cataract
22
57
 
23
58
  # Fast path: no nesting - use simple algorithm
24
59
  unless has_nesting
25
- return stylesheet_to_s_original(rules, media_index, result, selector_lists)
60
+ return _stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
26
61
  end
27
62
 
28
63
  # Build parent-child relationships
@@ -35,44 +70,60 @@ module Cataract
35
70
  rule_children[parent_id] << rule
36
71
  end
37
72
 
38
- # Build rule_id => media_symbol map
39
- rule_to_media = {}
40
- media_index.each do |media_sym, rule_ids|
41
- rule_ids.each do |rule_id|
42
- rule_to_media[rule_id] = media_sym
43
- end
73
+ # Build reverse map: media_query_id => list_id
74
+ mq_id_to_list_id = {}
75
+ media_query_lists.each do |list_id, mq_ids|
76
+ mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
44
77
  end
45
78
 
46
79
  # Serialize top-level rules only (those without parent_rule_id)
47
- current_media = nil
80
+ current_media_query_list_id = nil
81
+ current_media_query = nil
48
82
  in_media_block = false
49
83
 
50
84
  rules.each do |rule|
51
85
  # Skip rules that have a parent (they'll be serialized as nested)
52
86
  next if rule.parent_rule_id
53
87
 
54
- rule_media = rule_to_media[rule.id]
88
+ rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
89
+ rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
90
+ rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
55
91
 
56
- if rule_media.nil?
92
+ if rule_media_query.nil?
57
93
  # Close any open media block
58
94
  if in_media_block
59
95
  result << "}\n"
60
96
  in_media_block = false
61
- current_media = nil
97
+ current_media_query = nil
98
+ current_media_query_list_id = nil
62
99
  end
63
100
  else
64
- # Media query
65
- if current_media.nil? || current_media != rule_media
101
+ # Check if we need to open a new media block
102
+ # For lists: compare list_id
103
+ # For single queries: compare by content (type + conditions)
104
+ needs_new_block = if rule_media_query_list_id
105
+ current_media_query_list_id != rule_media_query_list_id
106
+ else
107
+ !current_media_query ||
108
+ current_media_query.type != rule_media_query.type ||
109
+ current_media_query.conditions != rule_media_query.conditions
110
+ end
111
+
112
+ if needs_new_block
66
113
  if in_media_block
67
114
  result << "}\n"
68
115
  end
69
- current_media = rule_media
70
- result << "@media #{current_media} {\n"
116
+ current_media_query = rule_media_query
117
+ current_media_query_list_id = rule_media_query_list_id
118
+
119
+ # Serialize the media query (or list)
120
+ media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
121
+ result << "@media #{media_query_string} {\n"
71
122
  in_media_block = true
72
123
  end
73
124
  end
74
125
 
75
- serialize_rule_with_nesting(result, rule, rule_children, rule_to_media)
126
+ _serialize_rule_with_nesting(result, rule, rule_children, media_queries)
76
127
  end
77
128
 
78
129
  if in_media_block
@@ -83,12 +134,13 @@ module Cataract
83
134
  end
84
135
 
85
136
  # Helper: serialize rules without nesting support (compact format)
86
- def self.stylesheet_to_s_original(rules, media_index, result, selector_lists)
137
+ def self._stylesheet_to_s_without_nesting(rules, result, selector_lists, media_queries = [], media_query_lists = {})
87
138
  _serialize_stylesheet_with_grouping(
88
139
  rules: rules,
89
- media_index: media_index,
90
140
  result: result,
91
141
  selector_lists: selector_lists,
142
+ media_queries: media_queries,
143
+ media_query_lists: media_query_lists,
92
144
  opening_brace: ' { ',
93
145
  closing_brace: " }\n",
94
146
  media_indent: '',
@@ -99,14 +151,14 @@ module Cataract
99
151
  end
100
152
 
101
153
  # Helper: serialize a rule with its nested children
102
- def self.serialize_rule_with_nesting(result, rule, rule_children, rule_to_media)
154
+ def self._serialize_rule_with_nesting(result, rule, rule_children, media_queries)
103
155
  # Start selector
104
156
  result << "#{rule.selector} { "
105
157
 
106
158
  # Serialize declarations
107
159
  has_declarations = !rule.declarations.empty?
108
160
  if has_declarations
109
- serialize_declarations(result, rule.declarations)
161
+ _serialize_declarations(result, rule.declarations)
110
162
  end
111
163
 
112
164
  # Get nested children for this rule
@@ -122,27 +174,32 @@ module Cataract
122
174
  end
123
175
 
124
176
  # Determine if we need to reconstruct the nested selector with &
125
- nested_selector = reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
177
+ nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
126
178
 
127
179
  # Check if this child has @media nesting (parent_rule_id present but nesting_style is nil)
128
- if child.nesting_style.nil? && rule_to_media[child.id]
180
+ if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
129
181
  # This is a nested @media rule
130
- media_sym = rule_to_media[child.id]
131
- result << "@media #{media_sym} { "
132
- serialize_declarations(result, child.declarations)
182
+ mq = media_queries[child.media_query_id]
183
+ media_query_string = if mq.conditions
184
+ mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
185
+ else
186
+ mq.type.to_s
187
+ end
188
+ result << "@media #{media_query_string} { "
189
+ _serialize_declarations(result, child.declarations)
133
190
 
134
191
  # Recursively serialize any children of this @media rule
135
192
  media_children = rule_children[child.id] || []
136
193
  media_children.each_with_index do |media_child, media_idx|
137
194
  result << ' ' if media_idx > 0 || !child.declarations.empty?
138
195
 
139
- nested_media_selector = reconstruct_nested_selector(
196
+ nested_media_selector = _reconstruct_nested_selector(
140
197
  child.selector, media_child.selector,
141
198
  media_child.nesting_style
142
199
  )
143
200
 
144
201
  result << "#{nested_media_selector} { "
145
- serialize_declarations(result, media_child.declarations)
202
+ _serialize_declarations(result, media_child.declarations)
146
203
  result << ' }'
147
204
  end
148
205
 
@@ -150,21 +207,21 @@ module Cataract
150
207
  else
151
208
  # Regular nested selector
152
209
  result << "#{nested_selector} { "
153
- serialize_declarations(result, child.declarations)
210
+ _serialize_declarations(result, child.declarations)
154
211
 
155
212
  # Recursively serialize any children of this nested rule
156
213
  grandchildren = rule_children[child.id] || []
157
214
  grandchildren.each_with_index do |grandchild, grandchild_idx|
158
215
  result << ' ' if grandchild_idx > 0 || !child.declarations.empty?
159
216
 
160
- nested_grandchild_selector = reconstruct_nested_selector(
217
+ nested_grandchild_selector = _reconstruct_nested_selector(
161
218
  child.selector,
162
219
  grandchild.selector,
163
220
  grandchild.nesting_style
164
221
  )
165
222
 
166
223
  result << "#{nested_grandchild_selector} { "
167
- serialize_declarations(result, grandchild.declarations)
224
+ _serialize_declarations(result, grandchild.declarations)
168
225
  result << ' }'
169
226
  end
170
227
 
@@ -178,7 +235,7 @@ module Cataract
178
235
  # Reconstruct nested selector representation
179
236
  # If nesting_style == 1 (explicit), try to use & notation
180
237
  # If nesting_style == 0 (implicit), use plain selector
181
- def self.reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
238
+ def self._reconstruct_nested_selector(parent_selector, child_selector, nesting_style)
182
239
  return child_selector if nesting_style.nil?
183
240
 
184
241
  if nesting_style == 1 # NESTING_STYLE_EXPLICIT
@@ -204,7 +261,7 @@ module Cataract
204
261
 
205
262
  # Helper: find all selectors from same list with matching declarations
206
263
  # Returns array of selectors that can be grouped, marks rules as processed
207
- def self.find_groupable_selectors(rule:, rules:, selector_lists:, processed_rule_ids:, rule_to_media:, current_media:)
264
+ def self._find_groupable_selectors(rule:, rules:, selector_lists:, processed_rule_ids:, current_media_query_id:)
208
265
  list_id = rule.selector_list_id
209
266
  rule_ids_in_list = selector_lists[list_id]
210
267
 
@@ -217,16 +274,16 @@ module Cataract
217
274
  # Find all rules in this list that have identical declarations AND same media context
218
275
  matching_selectors = []
219
276
  rule_ids_in_list.each do |rid|
220
- # Find the rule by ID
221
- other_rule = rules.find { |r| r.id == rid }
277
+ # Direct array access (O(1)) - rules[i].id == i invariant is guaranteed by parser
278
+ other_rule = rules[rid]
222
279
  next unless other_rule
223
280
  next if processed_rule_ids[rid]
224
281
 
225
- # Check same media context
226
- next if rule_to_media[rid] != current_media
282
+ # Check same media context (compare media_query_id directly)
283
+ next if other_rule.media_query_id != current_media_query_id
227
284
 
228
285
  # Check declarations match (compare arrays directly for performance)
229
- if declarations_equal?(rule.declarations, other_rule.declarations)
286
+ if _declarations_equal?(rule.declarations, other_rule.declarations)
230
287
  matching_selectors << other_rule.selector
231
288
  processed_rule_ids[rid] = true
232
289
  end
@@ -239,7 +296,6 @@ module Cataract
239
296
  # All formatting behavior controlled by kwargs to avoid mode flags and if/else branches
240
297
  def self._serialize_stylesheet_with_grouping(
241
298
  rules:,
242
- media_index:,
243
299
  result:,
244
300
  selector_lists:,
245
301
  opening_brace:, # ' { ' (compact) vs " {\n" (formatted)
@@ -247,23 +303,24 @@ module Cataract
247
303
  media_indent:, # '' (compact) vs ' ' (formatted)
248
304
  decl_indent_base:, # nil (compact) vs ' ' (formatted base rules)
249
305
  decl_indent_media:, # nil (compact) vs ' ' (formatted media rules)
250
- add_blank_lines: # false (compact) vs true (formatted)
306
+ add_blank_lines:, # false (compact) vs true (formatted)
307
+ media_queries: [], # Array of MediaQuery objects
308
+ media_query_lists: {} # Hash: list_id => array of MediaQuery IDs
251
309
  )
252
310
  grouping_enabled = selector_lists && !selector_lists.empty?
253
311
 
254
- # Build rule_id => media_symbol map
255
- rule_to_media = {}
256
- media_index.each do |media_sym, rule_ids|
257
- rule_ids.each do |rule_id|
258
- rule_to_media[rule_id] = media_sym
259
- end
312
+ # Build reverse map: media_query_id => list_id
313
+ mq_id_to_list_id = {}
314
+ media_query_lists.each do |list_id, mq_ids|
315
+ mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
260
316
  end
261
317
 
262
318
  # Track processed rules to avoid duplicates when grouping
263
319
  processed_rule_ids = {}
264
320
 
265
321
  # Iterate through rules in insertion order, grouping consecutive media queries
266
- current_media = nil
322
+ current_media_query_list_id = nil
323
+ current_media_query = nil
267
324
  in_media_block = false
268
325
  rule_index = 0
269
326
 
@@ -271,15 +328,18 @@ module Cataract
271
328
  # Skip if already processed (when grouped)
272
329
  next if processed_rule_ids[rule.id]
273
330
 
274
- rule_media = rule_to_media[rule.id]
331
+ rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
332
+ rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
333
+ rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
275
334
  is_first_rule = (rule_index == 0)
276
335
 
277
- if rule_media.nil?
336
+ if rule_media_query.nil?
278
337
  # Not in any media query - close any open media block first
279
338
  if in_media_block
280
339
  result << "}\n"
281
340
  in_media_block = false
282
- current_media = nil
341
+ current_media_query = nil
342
+ current_media_query_list_id = nil
283
343
  end
284
344
 
285
345
  # Add blank line prefix for non-first rules (formatted only)
@@ -287,35 +347,44 @@ module Cataract
287
347
 
288
348
  # Try to group with other rules from same selector list
289
349
  if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
290
- selectors = find_groupable_selectors(
350
+ selectors = _find_groupable_selectors(
291
351
  rule: rule,
292
352
  rules: rules,
293
353
  selector_lists: selector_lists,
294
354
  processed_rule_ids: processed_rule_ids,
295
- rule_to_media: rule_to_media,
296
- current_media: rule_media
355
+ current_media_query_id: rule_media_query_id
297
356
  )
298
357
 
299
358
  # Serialize with grouped selectors
300
359
  result << selectors.join(', ') << opening_brace
301
360
  if decl_indent_base
302
- serialize_declarations_formatted(result, rule.declarations, decl_indent_base)
361
+ _serialize_declarations_formatted(result, rule.declarations, decl_indent_base)
303
362
  else
304
- serialize_declarations(result, rule.declarations)
363
+ _serialize_declarations(result, rule.declarations)
305
364
  end
306
365
  result << closing_brace
307
366
  else
308
367
  # Serialize individual rule
309
368
  if decl_indent_base
310
- serialize_rule_formatted(result, rule, '', true)
369
+ _serialize_rule_formatted(result, rule, '', true)
311
370
  else
312
- serialize_rule(result, rule)
371
+ _serialize_rule(result, rule)
313
372
  end
314
373
  processed_rule_ids[rule.id] = true
315
374
  end
316
375
  else
317
376
  # This rule is in a media query
318
- if current_media.nil? || current_media != rule_media
377
+ # For lists: compare list_id
378
+ # For single queries: compare by content (type + conditions)
379
+ needs_new_block = if rule_media_query_list_id
380
+ current_media_query_list_id != rule_media_query_list_id
381
+ else
382
+ !current_media_query ||
383
+ current_media_query.type != rule_media_query.type ||
384
+ current_media_query.conditions != rule_media_query.conditions
385
+ end
386
+
387
+ if needs_new_block
319
388
  # Close previous media block if open
320
389
  if in_media_block
321
390
  result << "}\n"
@@ -325,36 +394,39 @@ module Cataract
325
394
  result << "\n" if add_blank_lines && !is_first_rule
326
395
 
327
396
  # Open new media block
328
- current_media = rule_media
329
- result << "@media #{current_media} {\n"
397
+ current_media_query = rule_media_query
398
+ current_media_query_list_id = rule_media_query_list_id
399
+
400
+ # Serialize the media query (or list)
401
+ media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
402
+ result << "@media #{media_query_string} {\n"
330
403
  in_media_block = true
331
404
  end
332
405
 
333
406
  # Try to group with other rules from same selector list
334
407
  if grouping_enabled && rule.is_a?(Rule) && rule.selector_list_id
335
- selectors = find_groupable_selectors(
408
+ selectors = _find_groupable_selectors(
336
409
  rule: rule,
337
410
  rules: rules,
338
411
  selector_lists: selector_lists,
339
412
  processed_rule_ids: processed_rule_ids,
340
- rule_to_media: rule_to_media,
341
- current_media: rule_media
413
+ current_media_query_id: rule_media_query_id
342
414
  )
343
415
 
344
416
  # Serialize with grouped selectors (with media indent)
345
417
  result << media_indent << selectors.join(', ') << opening_brace
346
418
  if decl_indent_media
347
- serialize_declarations_formatted(result, rule.declarations, decl_indent_media)
419
+ _serialize_declarations_formatted(result, rule.declarations, decl_indent_media)
348
420
  else
349
- serialize_declarations(result, rule.declarations)
421
+ _serialize_declarations(result, rule.declarations)
350
422
  end
351
423
  result << media_indent << closing_brace
352
424
  else
353
425
  # Serialize individual rule inside media block
354
426
  if decl_indent_media
355
- serialize_rule_formatted(result, rule, media_indent, true)
427
+ _serialize_rule_formatted(result, rule, media_indent, true)
356
428
  else
357
- serialize_rule(result, rule)
429
+ _serialize_rule(result, rule)
358
430
  end
359
431
  processed_rule_ids[rule.id] = true
360
432
  end
@@ -373,7 +445,7 @@ module Cataract
373
445
  private_class_method :_serialize_stylesheet_with_grouping
374
446
 
375
447
  # Helper: check if two declaration arrays are equal
376
- def self.declarations_equal?(decls1, decls2)
448
+ def self._declarations_equal?(decls1, decls2)
377
449
  return false if decls1.size != decls2.size
378
450
 
379
451
  decls1.each_with_index do |d1, i|
@@ -387,21 +459,21 @@ module Cataract
387
459
  end
388
460
 
389
461
  # Helper: serialize a single rule
390
- def self.serialize_rule(result, rule)
462
+ def self._serialize_rule(result, rule)
391
463
  # Check if this is an AtRule
392
464
  if rule.is_a?(AtRule)
393
- serialize_at_rule(result, rule)
465
+ _serialize_at_rule(result, rule)
394
466
  return
395
467
  end
396
468
 
397
469
  # Regular Rule serialization
398
470
  result << "#{rule.selector} { "
399
- serialize_declarations(result, rule.declarations)
471
+ _serialize_declarations(result, rule.declarations)
400
472
  result << " }\n"
401
473
  end
402
474
 
403
475
  # Helper: serialize declarations (compact, single line)
404
- def self.serialize_declarations(result, declarations)
476
+ def self._serialize_declarations(result, declarations)
405
477
  declarations.each_with_index do |decl, i|
406
478
  important_suffix = decl.important ? ' !important;' : ';'
407
479
  separator = i < declarations.length - 1 ? ' ' : ''
@@ -410,7 +482,7 @@ module Cataract
410
482
  end
411
483
 
412
484
  # Helper: serialize declarations (formatted, one per line)
413
- def self.serialize_declarations_formatted(result, declarations, indent)
485
+ def self._serialize_declarations_formatted(result, declarations, indent)
414
486
  declarations.each do |decl|
415
487
  result << indent
416
488
  result << decl.property
@@ -426,7 +498,7 @@ module Cataract
426
498
  end
427
499
 
428
500
  # Helper: serialize an at-rule (@keyframes, @font-face, etc)
429
- def self.serialize_at_rule(result, at_rule)
501
+ def self._serialize_at_rule(result, at_rule)
430
502
  result << "#{at_rule.selector} {\n"
431
503
 
432
504
  # Check if content is rules or declarations
@@ -437,13 +509,13 @@ module Cataract
437
509
  # Serialize as nested rules (e.g., @keyframes)
438
510
  at_rule.content.each do |nested_rule|
439
511
  result << " #{nested_rule.selector} { "
440
- serialize_declarations(result, nested_rule.declarations)
512
+ _serialize_declarations(result, nested_rule.declarations)
441
513
  result << " }\n"
442
514
  end
443
515
  else
444
516
  # Serialize as declarations (e.g., @font-face)
445
517
  result << ' '
446
- serialize_declarations(result, at_rule.content)
518
+ _serialize_declarations(result, at_rule.content)
447
519
  result << "\n"
448
520
  end
449
521
  end
@@ -454,12 +526,13 @@ module Cataract
454
526
  # Serialize stylesheet to formatted CSS string (with indentation)
455
527
  #
456
528
  # @param rules [Array<Rule>] Array of rules
457
- # @param media_index [Hash] Media query symbol => array of rule IDs
458
529
  # @param charset [String, nil] @charset value
459
530
  # @param has_nesting [Boolean] Whether any nested rules exist
460
531
  # @param selector_lists [Hash] Selector list ID => array of rule IDs (for grouping)
532
+ # @param media_queries [Array<MediaQuery>] Array of MediaQuery objects (optional, for proper serialization)
533
+ # @param media_query_lists [Hash] List ID => array of MediaQuery IDs (optional, for comma-separated queries)
461
534
  # @return [String] Formatted CSS string
462
- def self._stylesheet_to_formatted_s(rules, media_index, charset, has_nesting, selector_lists = {})
535
+ def self.stylesheet_to_formatted_s(rules, charset, has_nesting, selector_lists = {}, media_queries = [], media_query_lists = {})
463
536
  result = +''
464
537
 
465
538
  # Add @charset if present
@@ -469,7 +542,7 @@ module Cataract
469
542
 
470
543
  # Fast path: no nesting - use simple algorithm
471
544
  unless has_nesting
472
- return stylesheet_to_formatted_s_original(rules, media_index, result, selector_lists)
545
+ return _stylesheet_to_formatted_s_without_nesting(rules, result, selector_lists, media_queries, media_query_lists)
473
546
  end
474
547
 
475
548
  # Build parent-child relationships
@@ -482,47 +555,62 @@ module Cataract
482
555
  rule_children[parent_id] << rule
483
556
  end
484
557
 
485
- # Build rule_id => media_symbol map
486
- rule_to_media = {}
487
- media_index.each do |media_sym, rule_ids|
488
- rule_ids.each do |rule_id|
489
- rule_to_media[rule_id] = media_sym
490
- end
558
+ # Build reverse map: media_query_id => list_id
559
+ mq_id_to_list_id = {}
560
+ media_query_lists.each do |list_id, mq_ids|
561
+ mq_ids.each { |mq_id| mq_id_to_list_id[mq_id] = list_id }
491
562
  end
492
563
 
493
564
  # Serialize top-level rules only
494
- current_media = nil
565
+ current_media_query_list_id = nil
566
+ current_media_query = nil
495
567
  in_media_block = false
496
568
 
497
569
  rules.each do |rule|
498
570
  next if rule.parent_rule_id
499
571
 
500
- rule_media = rule_to_media[rule.id]
572
+ rule_media_query_id = rule.is_a?(Rule) ? rule.media_query_id : nil
573
+ rule_media_query = rule_media_query_id ? media_queries[rule_media_query_id] : nil
574
+ rule_media_query_list_id = rule_media_query_id ? mq_id_to_list_id[rule_media_query_id] : nil
501
575
 
502
- if rule_media.nil?
576
+ if rule_media_query.nil?
503
577
  if in_media_block
504
578
  result << "}\n"
505
579
  in_media_block = false
506
- current_media = nil
580
+ current_media_query = nil
581
+ current_media_query_list_id = nil
507
582
  end
508
583
 
509
584
  # Add blank line before base rule if we just closed a media block (ends with "}\n")
510
585
  result << "\n" if result.length > 1 && result.getbyte(-1) == BYTE_NEWLINE && result.getbyte(-2) == BYTE_RBRACE
511
586
 
512
- serialize_rule_with_nesting_formatted(result, rule, rule_children, rule_to_media, '')
587
+ _serialize_rule_with_nesting_formatted(result, rule, rule_children, '', media_queries)
513
588
  else
514
- if current_media.nil? || current_media != rule_media
589
+ # For lists: compare list_id
590
+ # For single queries: compare by content (type + conditions)
591
+ needs_new_block = if rule_media_query_list_id
592
+ current_media_query_list_id != rule_media_query_list_id
593
+ else
594
+ !current_media_query ||
595
+ current_media_query.type != rule_media_query.type ||
596
+ current_media_query.conditions != rule_media_query.conditions
597
+ end
598
+
599
+ if needs_new_block
515
600
  if in_media_block
516
601
  result << "}\n"
517
602
  elsif result.length > 0
518
603
  result << "\n"
519
604
  end
520
- current_media = rule_media
521
- result << "@media #{current_media} {\n"
605
+ current_media_query = rule_media_query
606
+ current_media_query_list_id = rule_media_query_list_id
607
+ # Serialize the media query (or list)
608
+ media_query_string = _build_media_query_string(rule_media_query, rule_media_query_list_id, media_query_lists, media_queries)
609
+ result << "@media #{media_query_string} {\n"
522
610
  in_media_block = true
523
611
  end
524
612
 
525
- serialize_rule_with_nesting_formatted(result, rule, rule_children, rule_to_media, ' ')
613
+ _serialize_rule_with_nesting_formatted(result, rule, rule_children, ' ', media_queries)
526
614
  end
527
615
  end
528
616
 
@@ -534,12 +622,13 @@ module Cataract
534
622
  end
535
623
 
536
624
  # Helper: formatted serialization without nesting support
537
- def self.stylesheet_to_formatted_s_original(rules, media_index, result, selector_lists)
625
+ def self._stylesheet_to_formatted_s_without_nesting(rules, result, selector_lists, media_queries = [], media_query_lists = {})
538
626
  _serialize_stylesheet_with_grouping(
539
627
  rules: rules,
540
- media_index: media_index,
541
628
  result: result,
542
629
  selector_lists: selector_lists,
630
+ media_queries: media_queries,
631
+ media_query_lists: media_query_lists,
543
632
  opening_brace: " {\n",
544
633
  closing_brace: "}\n",
545
634
  media_indent: ' ',
@@ -550,7 +639,7 @@ module Cataract
550
639
  end
551
640
 
552
641
  # Helper: serialize a rule with nested children (formatted)
553
- def self.serialize_rule_with_nesting_formatted(result, rule, rule_children, rule_to_media, indent)
642
+ def self._serialize_rule_with_nesting_formatted(result, rule, rule_children, indent, media_queries)
554
643
  # Selector line with opening brace
555
644
  result << indent
556
645
  result << rule.selector
@@ -558,7 +647,7 @@ module Cataract
558
647
 
559
648
  # Serialize declarations (one per line)
560
649
  unless rule.declarations.empty?
561
- serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
650
+ _serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
562
651
  end
563
652
 
564
653
  # Get nested children
@@ -566,22 +655,27 @@ module Cataract
566
655
 
567
656
  # Serialize nested children
568
657
  children.each do |child|
569
- nested_selector = reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
658
+ nested_selector = _reconstruct_nested_selector(rule.selector, child.selector, child.nesting_style)
570
659
 
571
- if child.nesting_style.nil? && rule_to_media[child.id]
660
+ if child.nesting_style.nil? && child.media_query_id && media_queries[child.media_query_id]
572
661
  # Nested @media
573
- media_sym = rule_to_media[child.id]
662
+ mq = media_queries[child.media_query_id]
663
+ media_query_string = if mq.conditions
664
+ mq.type == :all ? mq.conditions : "#{mq.type} and #{mq.conditions}"
665
+ else
666
+ mq.type.to_s
667
+ end
574
668
  result << indent
575
- result << " @media #{media_sym} {\n"
669
+ result << " @media #{media_query_string} {\n"
576
670
 
577
671
  unless child.declarations.empty?
578
- serialize_declarations_formatted(result, child.declarations, "#{indent} ")
672
+ _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
579
673
  end
580
674
 
581
675
  # Recursively handle media children
582
676
  media_children = rule_children[child.id] || []
583
677
  media_children.each do |media_child|
584
- nested_media_selector = reconstruct_nested_selector(
678
+ nested_media_selector = _reconstruct_nested_selector(
585
679
  child.selector,
586
680
  media_child.selector,
587
681
  media_child.nesting_style
@@ -590,7 +684,7 @@ module Cataract
590
684
  result << indent
591
685
  result << " #{nested_media_selector} {\n"
592
686
  unless media_child.declarations.empty?
593
- serialize_declarations_formatted(result, media_child.declarations, "#{indent} ")
687
+ _serialize_declarations_formatted(result, media_child.declarations, "#{indent} ")
594
688
  end
595
689
  result << indent
596
690
  result << " }\n"
@@ -604,13 +698,13 @@ module Cataract
604
698
  result << " #{nested_selector} {\n"
605
699
 
606
700
  unless child.declarations.empty?
607
- serialize_declarations_formatted(result, child.declarations, "#{indent} ")
701
+ _serialize_declarations_formatted(result, child.declarations, "#{indent} ")
608
702
  end
609
703
 
610
704
  # Recursively handle grandchildren
611
705
  grandchildren = rule_children[child.id] || []
612
706
  grandchildren.each do |grandchild|
613
- nested_grandchild_selector = reconstruct_nested_selector(
707
+ nested_grandchild_selector = _reconstruct_nested_selector(
614
708
  child.selector,
615
709
  grandchild.selector,
616
710
  grandchild.nesting_style
@@ -619,7 +713,7 @@ module Cataract
619
713
  result << indent
620
714
  result << " #{nested_grandchild_selector} {\n"
621
715
  unless grandchild.declarations.empty?
622
- serialize_declarations_formatted(result, grandchild.declarations, "#{indent} ")
716
+ _serialize_declarations_formatted(result, grandchild.declarations, "#{indent} ")
623
717
  end
624
718
  result << indent
625
719
  result << " }\n"
@@ -636,10 +730,10 @@ module Cataract
636
730
  end
637
731
 
638
732
  # Helper: serialize a single rule with formatting
639
- def self.serialize_rule_formatted(result, rule, indent, is_last_rule = false)
733
+ def self._serialize_rule_formatted(result, rule, indent, is_last_rule = false)
640
734
  # Check if this is an AtRule
641
735
  if rule.is_a?(AtRule)
642
- serialize_at_rule_formatted(result, rule, indent)
736
+ _serialize_at_rule_formatted(result, rule, indent)
643
737
  return
644
738
  end
645
739
 
@@ -650,7 +744,7 @@ module Cataract
650
744
  result << " {\n"
651
745
 
652
746
  # Declarations (one per line)
653
- serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
747
+ _serialize_declarations_formatted(result, rule.declarations, "#{indent} ")
654
748
 
655
749
  # Closing brace - double newline for all except last rule
656
750
  result << indent
@@ -658,7 +752,7 @@ module Cataract
658
752
  end
659
753
 
660
754
  # Helper: serialize an at-rule with formatting
661
- def self.serialize_at_rule_formatted(result, at_rule, indent)
755
+ def self._serialize_at_rule_formatted(result, at_rule, indent)
662
756
  result << indent
663
757
  result << at_rule.selector
664
758
  result << " {\n"
@@ -677,7 +771,7 @@ module Cataract
677
771
  result << " {\n"
678
772
 
679
773
  # Declarations (one per line, 4-space indent)
680
- serialize_declarations_formatted(result, nested_rule.declarations, "#{indent} ")
774
+ _serialize_declarations_formatted(result, nested_rule.declarations, "#{indent} ")
681
775
 
682
776
  # Closing brace (2-space indent)
683
777
  result << indent
@@ -685,11 +779,18 @@ module Cataract
685
779
  end
686
780
  else
687
781
  # Serialize as declarations (e.g., @font-face, one per line)
688
- serialize_declarations_formatted(result, at_rule.content, "#{indent} ")
782
+ _serialize_declarations_formatted(result, at_rule.content, "#{indent} ")
689
783
  end
690
784
  end
691
785
 
692
786
  result << indent
693
787
  result << "}\n"
694
788
  end
789
+
790
+ # Mark helper methods as private (public APIs: stylesheet_to_s, stylesheet_to_formatted_s)
791
+ private_class_method :_build_media_query_string, :_stylesheet_to_s_without_nesting, :_serialize_rule_with_nesting,
792
+ :_reconstruct_nested_selector, :_find_groupable_selectors, :_declarations_equal?,
793
+ :_serialize_rule, :_serialize_declarations, :_serialize_declarations_formatted,
794
+ :_serialize_at_rule, :_stylesheet_to_formatted_s_without_nesting, :_serialize_rule_with_nesting_formatted,
795
+ :_serialize_rule_formatted, :_serialize_at_rule_formatted
695
796
  end