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.
@@ -620,24 +620,87 @@ struct flatten_selectors_context {
620
620
  int *rule_id_counter;
621
621
  long selector_index;
622
622
  long total_selectors;
623
+ VALUE old_to_new_id; // Hash mapping old rule IDs to new merged rule IDs (for media index)
623
624
  };
624
625
 
626
+ // Context for remapping media_index
627
+ struct remap_media_context {
628
+ VALUE old_to_new_id;
629
+ VALUE new_media_index;
630
+ };
631
+
632
+ // Callback for remapping media_index entries
633
+ static int remap_media_index_callback(VALUE media_sym, VALUE old_rule_ids, VALUE arg) {
634
+ struct remap_media_context *ctx = (struct remap_media_context *)arg;
635
+
636
+ if (NIL_P(old_rule_ids) || TYPE(old_rule_ids) != T_ARRAY) {
637
+ return ST_CONTINUE;
638
+ }
639
+
640
+ VALUE new_rule_ids = rb_ary_new();
641
+ long num_old_ids = RARRAY_LEN(old_rule_ids);
642
+
643
+ for (long i = 0; i < num_old_ids; i++) {
644
+ VALUE old_id = RARRAY_AREF(old_rule_ids, i);
645
+ VALUE new_id = rb_hash_aref(ctx->old_to_new_id, old_id);
646
+
647
+ // Only include if the rule still exists after merging and isn't already in the list
648
+ if (!NIL_P(new_id) && rb_ary_includes(new_rule_ids, new_id) == Qfalse) {
649
+ rb_ary_push(new_rule_ids, new_id);
650
+ }
651
+ }
652
+
653
+ // Only preserve media entry if there are still rules for it
654
+ if (RARRAY_LEN(new_rule_ids) > 0) {
655
+ rb_hash_aset(ctx->new_media_index, media_sym, new_rule_ids);
656
+ }
657
+
658
+ return ST_CONTINUE;
659
+ }
660
+
625
661
  // Forward declaration
626
662
  static VALUE flatten_rules_for_selector(VALUE rules_array, VALUE rule_indices, VALUE selector, VALUE *out_selector_list_id);
627
663
 
628
664
  // Callback for rb_hash_foreach when merging selector groups
629
- static int flatten_selector_group_callback(VALUE selector, VALUE group_indices, VALUE arg) {
665
+ static int flatten_selector_group_callback(VALUE group_key, VALUE group_indices, VALUE arg) {
630
666
  struct flatten_selectors_context *ctx = (struct flatten_selectors_context *)arg;
631
667
  ctx->selector_index++;
632
668
 
633
- DEBUG_PRINTF("\n[Selector %ld/%ld] '%s' - %ld rules in group\n",
669
+ // Extract selector and media_context from group_key array [selector, media_context]
670
+ VALUE selector = RARRAY_AREF(group_key, 0);
671
+ VALUE media_context = RARRAY_AREF(group_key, 1);
672
+
673
+ DEBUG_PRINTF("\n[Selector %ld/%ld] '%s' (media=%s) - %ld rules in group\n",
634
674
  ctx->selector_index, ctx->total_selectors,
635
- RSTRING_PTR(selector), RARRAY_LEN(group_indices));
675
+ RSTRING_PTR(selector),
676
+ NIL_P(media_context) ? "nil" : RSTRING_PTR(rb_inspect(media_context)),
677
+ RARRAY_LEN(group_indices));
636
678
 
637
679
  // Merge all rules in this selector group and preserve selector_list_id if all rules share same ID
638
680
  VALUE selector_list_id = Qnil;
639
681
  VALUE merged_decls = flatten_rules_for_selector(ctx->rules_array, group_indices, selector, &selector_list_id);
640
682
 
683
+ int new_rule_id = *ctx->rule_id_counter;
684
+
685
+ // Extract media_query_id from first rule in group (all should have same media_query_id)
686
+ VALUE media_query_id = Qnil;
687
+ if (RARRAY_LEN(group_indices) > 0) {
688
+ long first_rule_idx = FIX2LONG(RARRAY_AREF(group_indices, 0));
689
+ VALUE first_rule = RARRAY_AREF(ctx->rules_array, first_rule_idx);
690
+ media_query_id = rb_struct_aref(first_rule, INT2FIX(RULE_MEDIA_QUERY_ID));
691
+ }
692
+
693
+ // Track old rule IDs to new rule ID mapping (only for rules in media queries)
694
+ if (!NIL_P(media_context)) {
695
+ long num_rules_in_group = RARRAY_LEN(group_indices);
696
+ for (long i = 0; i < num_rules_in_group; i++) {
697
+ long old_rule_idx = FIX2LONG(RARRAY_AREF(group_indices, i));
698
+ VALUE old_rule = RARRAY_AREF(ctx->rules_array, old_rule_idx);
699
+ VALUE old_rule_id = rb_struct_aref(old_rule, INT2FIX(RULE_ID));
700
+ rb_hash_aset(ctx->old_to_new_id, old_rule_id, INT2FIX(new_rule_id));
701
+ }
702
+ }
703
+
641
704
  // Create new rule with this selector and merged declarations
642
705
  VALUE new_rule = rb_struct_new(cRule,
643
706
  INT2FIX((*ctx->rule_id_counter)++),
@@ -646,7 +709,8 @@ static int flatten_selector_group_callback(VALUE selector, VALUE group_indices,
646
709
  Qnil, // specificity
647
710
  Qnil, // parent_rule_id
648
711
  Qnil, // nesting_style
649
- selector_list_id // Preserve selector_list_id if all rules in group share same ID
712
+ selector_list_id, // Preserve selector_list_id if all rules in group share same ID
713
+ media_query_id // Preserve media_query_id from source rules
650
714
  );
651
715
  rb_ary_push(ctx->merged_rules, new_rule);
652
716
 
@@ -1114,6 +1178,81 @@ static int declarations_equal(VALUE decls1, VALUE decls2) {
1114
1178
  return 1;
1115
1179
  }
1116
1180
 
1181
+ // Context for iterating through rules_by_list hash
1182
+ struct check_selector_lists_ctx {
1183
+ VALUE selector_lists; // Output hash to populate
1184
+ };
1185
+
1186
+ // Callback for iterating through rules_by_list hash: list_id => [rule1, rule2, ...]
1187
+ static int check_selector_list_callback(VALUE list_id, VALUE rules_in_list, VALUE arg) {
1188
+ struct check_selector_lists_ctx *ctx = (struct check_selector_lists_ctx *)arg;
1189
+ long num_in_list = RARRAY_LEN(rules_in_list);
1190
+
1191
+ DEBUG_PRINTF("\n Checking list_id=%ld: %ld rules\n", NUM2LONG(list_id), num_in_list);
1192
+
1193
+ // Skip if only one rule in list (nothing to compare)
1194
+ if (num_in_list <= 1) {
1195
+ DEBUG_PRINTF(" -> Only 1 rule, skipping\n");
1196
+ return ST_CONTINUE;
1197
+ }
1198
+
1199
+ // Get first rule as reference
1200
+ VALUE reference_rule = RARRAY_AREF(rules_in_list, 0);
1201
+ VALUE reference_decls = rb_struct_aref(reference_rule, INT2FIX(RULE_DECLARATIONS));
1202
+ #ifdef CATARACT_DEBUG
1203
+ VALUE reference_selector = rb_struct_aref(reference_rule, INT2FIX(RULE_SELECTOR));
1204
+ DEBUG_PRINTF(" Reference rule: selector=%s, %ld declarations\n",
1205
+ RSTRING_PTR(reference_selector), RARRAY_LEN(reference_decls));
1206
+ #endif
1207
+
1208
+ // Find rules that still match (have identical declarations)
1209
+ VALUE matching_rules = rb_ary_new();
1210
+ rb_ary_push(matching_rules, reference_rule);
1211
+
1212
+ for (long j = 1; j < num_in_list; j++) {
1213
+ VALUE rule = RARRAY_AREF(rules_in_list, j);
1214
+ VALUE decls = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1215
+ #ifdef CATARACT_DEBUG
1216
+ VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1217
+ DEBUG_PRINTF(" Comparing rule %ld (selector=%s):\n", j, RSTRING_PTR(selector));
1218
+ #endif
1219
+
1220
+ if (declarations_equal(reference_decls, decls)) {
1221
+ DEBUG_PRINTF(" -> MATCHES reference, keeping in list\n");
1222
+ rb_ary_push(matching_rules, rule);
1223
+ } else {
1224
+ DEBUG_PRINTF(" -> DIVERGED from reference, clearing selector_list_id\n");
1225
+ // Clear selector_list_id for diverged rule
1226
+ rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1227
+ }
1228
+ }
1229
+
1230
+ // Only keep the selector list if at least 2 rules still match
1231
+ long num_matching = RARRAY_LEN(matching_rules);
1232
+ DEBUG_PRINTF(" Result: %ld/%ld rules still match\n", num_matching, num_in_list);
1233
+
1234
+ if (num_matching >= 2) {
1235
+ // Build selector_lists hash with NEW rule IDs
1236
+ VALUE rule_ids = rb_ary_new_capa(num_matching);
1237
+ for (long j = 0; j < num_matching; j++) {
1238
+ VALUE rule = RARRAY_AREF(matching_rules, j);
1239
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
1240
+ rb_ary_push(rule_ids, rule_id);
1241
+ }
1242
+ rb_hash_aset(ctx->selector_lists, list_id, rule_ids);
1243
+ DEBUG_PRINTF(" -> Keeping selector list with %ld rules\n", num_matching);
1244
+ } else {
1245
+ DEBUG_PRINTF(" -> Only 1 rule left, clearing selector_list_id for it too\n");
1246
+ // Clear selector_list_id for the last remaining rule too
1247
+ for (long j = 0; j < num_matching; j++) {
1248
+ VALUE rule = RARRAY_AREF(matching_rules, j);
1249
+ rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1250
+ }
1251
+ }
1252
+
1253
+ return ST_CONTINUE;
1254
+ }
1255
+
1117
1256
  /*
1118
1257
  * Update selector lists to remove diverged rules
1119
1258
  *
@@ -1165,77 +1304,9 @@ static void update_selector_lists_for_divergence(VALUE merged_rules, VALUE selec
1165
1304
  }
1166
1305
 
1167
1306
  // For each selector list, check if declarations still match
1168
- VALUE list_ids = rb_funcall(rules_by_list, rb_intern("keys"), 0);
1169
- long num_lists = RARRAY_LEN(list_ids);
1170
- DEBUG_PRINTF(" Found %ld selector list groups to check\n", num_lists);
1171
-
1172
- for (long i = 0; i < num_lists; i++) {
1173
- VALUE list_id = RARRAY_AREF(list_ids, i);
1174
- VALUE rules_in_list = rb_hash_aref(rules_by_list, list_id);
1175
- long num_in_list = RARRAY_LEN(rules_in_list);
1176
-
1177
- DEBUG_PRINTF("\n Checking list_id=%ld: %ld rules\n", NUM2LONG(list_id), num_in_list);
1178
-
1179
- // Skip if only one rule in list (nothing to compare)
1180
- if (num_in_list <= 1) {
1181
- DEBUG_PRINTF(" -> Only 1 rule, skipping\n");
1182
- continue;
1183
- }
1184
-
1185
- // Get first rule as reference
1186
- VALUE reference_rule = RARRAY_AREF(rules_in_list, 0);
1187
- VALUE reference_decls = rb_struct_aref(reference_rule, INT2FIX(RULE_DECLARATIONS));
1188
- #ifdef CATARACT_DEBUG
1189
- VALUE reference_selector = rb_struct_aref(reference_rule, INT2FIX(RULE_SELECTOR));
1190
- DEBUG_PRINTF(" Reference rule: selector=%s, %ld declarations\n",
1191
- RSTRING_PTR(reference_selector), RARRAY_LEN(reference_decls));
1192
- #endif
1193
-
1194
- // Find rules that still match (have identical declarations)
1195
- VALUE matching_rules = rb_ary_new();
1196
- rb_ary_push(matching_rules, reference_rule);
1197
-
1198
- for (long j = 1; j < num_in_list; j++) {
1199
- VALUE rule = RARRAY_AREF(rules_in_list, j);
1200
- VALUE decls = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1201
- #ifdef CATARACT_DEBUG
1202
- VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1203
- DEBUG_PRINTF(" Comparing rule %ld (selector=%s):\n", j, RSTRING_PTR(selector));
1204
- #endif
1205
-
1206
- if (declarations_equal(reference_decls, decls)) {
1207
- DEBUG_PRINTF(" -> MATCHES reference, keeping in list\n");
1208
- rb_ary_push(matching_rules, rule);
1209
- } else {
1210
- DEBUG_PRINTF(" -> DIVERGED from reference, clearing selector_list_id\n");
1211
- // Clear selector_list_id for diverged rule
1212
- rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1213
- }
1214
- }
1215
-
1216
- // Only keep the selector list if at least 2 rules still match
1217
- long num_matching = RARRAY_LEN(matching_rules);
1218
- DEBUG_PRINTF(" Result: %ld/%ld rules still match\n", num_matching, num_in_list);
1219
-
1220
- if (num_matching >= 2) {
1221
- // Build selector_lists hash with NEW rule IDs
1222
- VALUE rule_ids = rb_ary_new_capa(num_matching);
1223
- for (long j = 0; j < num_matching; j++) {
1224
- VALUE rule = RARRAY_AREF(matching_rules, j);
1225
- VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
1226
- rb_ary_push(rule_ids, rule_id);
1227
- }
1228
- rb_hash_aset(selector_lists, list_id, rule_ids);
1229
- DEBUG_PRINTF(" -> Keeping selector list with %ld rules\n", num_matching);
1230
- } else {
1231
- DEBUG_PRINTF(" -> Only 1 rule left, clearing selector_list_id for it too\n");
1232
- // Clear selector_list_id for the last remaining rule too
1233
- for (long j = 0; j < num_matching; j++) {
1234
- VALUE rule = RARRAY_AREF(matching_rules, j);
1235
- rb_struct_aset(rule, INT2FIX(RULE_SELECTOR_LIST_ID), Qnil);
1236
- }
1237
- }
1238
- }
1307
+ DEBUG_PRINTF(" Found %ld selector list groups to check\n", RHASH_SIZE(rules_by_list));
1308
+ struct check_selector_lists_ctx ctx = { selector_lists };
1309
+ rb_hash_foreach(rules_by_list, check_selector_list_callback, (VALUE)&ctx);
1239
1310
 
1240
1311
  DEBUG_PRINTF("\n=== End divergence tracking: %ld selector lists preserved ===\n\n", RHASH_SIZE(selector_lists));
1241
1312
  }
@@ -1372,10 +1443,32 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1372
1443
  }
1373
1444
  }
1374
1445
 
1375
- // ALWAYS build selector groups - this is the core of flatten logic
1376
- // Group rules by selector: different selectors stay separate
1377
- // selector => [rule indices]
1378
- DEBUG_PRINTF("\n=== Building selector groups (has_nesting=%d) ===\n", has_nesting);
1446
+ // Build rule_media_map: rule_id => media_query_id
1447
+ // This is used to group rules by (selector, media) instead of just selector
1448
+ VALUE input_media_index = Qnil;
1449
+ VALUE rule_media_map = rb_hash_new();
1450
+ if (rb_obj_is_kind_of(input, cStylesheet)) {
1451
+ input_media_index = rb_ivar_get(input, id_ivar_media_index);
1452
+
1453
+ // Build map from rules' media_query_id field
1454
+ // Only process Rule objects, not AtRules (AtRules don't have media_query_id field at same offset)
1455
+ for (long i = 0; i < num_rules; i++) {
1456
+ VALUE rule = rb_ary_entry(rules_array, i);
1457
+ if (!rb_obj_is_kind_of(rule, cAtRule)) {
1458
+ VALUE media_query_id = rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
1459
+ if (!NIL_P(media_query_id)) {
1460
+ VALUE rule_id = rb_struct_aref(rule, INT2FIX(RULE_ID));
1461
+ // Store media_query_id as the grouping key
1462
+ rb_hash_aset(rule_media_map, rule_id, media_query_id);
1463
+ }
1464
+ }
1465
+ }
1466
+ }
1467
+
1468
+ // Group rules by (selector, media) instead of just selector
1469
+ // Rules with same selector but different media contexts should NOT be merged
1470
+ // selector_and_media_key => [rule indices]
1471
+ DEBUG_PRINTF("\n=== Building selector+media groups (has_nesting=%d) ===\n", has_nesting);
1379
1472
  VALUE selector_groups = rb_hash_new();
1380
1473
  VALUE passthrough_rules = rb_ary_new(); // AtRules to pass through unchanged
1381
1474
 
@@ -1392,6 +1485,7 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1392
1485
 
1393
1486
  VALUE declarations = rb_struct_aref(rule, INT2FIX(RULE_DECLARATIONS));
1394
1487
  VALUE selector = rb_struct_aref(rule, INT2FIX(RULE_SELECTOR));
1488
+ VALUE rule_id_val = rb_struct_aref(rule, INT2FIX(RULE_ID));
1395
1489
 
1396
1490
  // Skip empty rules (no declarations)
1397
1491
  // This handles both empty containers and rules with no properties
@@ -1407,18 +1501,28 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1407
1501
  // Should output both .parent (color: red) and .parent .child (color: blue)
1408
1502
  // The nesting is already flattened during parsing, so they have different selectors.
1409
1503
 
1410
- DEBUG_PRINTF(" [Rule %ld] ADD: selector='%s', %ld declarations\n",
1411
- i, RSTRING_PTR(selector), RARRAY_LEN(declarations));
1504
+ // Get media context for this rule (nil if not in media query)
1505
+ VALUE media_context = rb_hash_aref(rule_media_map, rule_id_val);
1506
+
1507
+ // Build grouping key as [selector, media_context]
1508
+ VALUE group_key = rb_ary_new3(2, selector, media_context);
1509
+
1510
+ DEBUG_PRINTF(" [Rule %ld] ADD: selector='%s', media=%s, %ld declarations\n",
1511
+ i, RSTRING_PTR(selector),
1512
+ NIL_P(media_context) ? "nil" : RSTRING_PTR(rb_inspect(media_context)),
1513
+ RARRAY_LEN(declarations));
1412
1514
 
1413
- VALUE group = rb_hash_aref(selector_groups, selector);
1515
+ VALUE group = rb_hash_aref(selector_groups, group_key);
1414
1516
  if (NIL_P(group)) {
1415
1517
  group = rb_ary_new();
1416
- rb_hash_aset(selector_groups, selector, group);
1417
- DEBUG_PRINTF(" -> Created new selector group for '%s'\n", RSTRING_PTR(selector));
1518
+ rb_hash_aset(selector_groups, group_key, group);
1519
+ DEBUG_PRINTF(" -> Created new selector+media group for '%s' + %s\n",
1520
+ RSTRING_PTR(selector),
1521
+ NIL_P(media_context) ? "nil" : RSTRING_PTR(rb_inspect(media_context)));
1418
1522
  }
1419
1523
  rb_ary_push(group, LONG2FIX(i));
1420
1524
  }
1421
- DEBUG_PRINTF("=== Total selector groups: %ld ===\n\n", RHASH_SIZE(selector_groups));
1525
+ DEBUG_PRINTF("=== Total selector+media groups: %ld ===\n\n", RHASH_SIZE(selector_groups));
1422
1526
 
1423
1527
  // ALWAYS group by selector and keep them separate
1424
1528
  // Different selectors target different elements and must remain distinct
@@ -1444,6 +1548,16 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1444
1548
  VALUE media_idx = rb_hash_new();
1445
1549
  rb_ivar_set(passthrough_sheet, id_ivar_media_index, media_idx);
1446
1550
 
1551
+ // Copy @media_queries and @_media_query_lists from input
1552
+ if (rb_obj_is_kind_of(input, cStylesheet)) {
1553
+ VALUE media_queries = rb_ivar_get(input, rb_intern("@media_queries"));
1554
+ VALUE media_query_lists = rb_ivar_get(input, rb_intern("@_media_query_lists"));
1555
+ Check_Type(media_queries, T_ARRAY);
1556
+ if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
1557
+ rb_ivar_set(passthrough_sheet, rb_intern("@media_queries"), media_queries);
1558
+ rb_ivar_set(passthrough_sheet, rb_intern("@_media_query_lists"), media_query_lists);
1559
+ }
1560
+
1447
1561
  return passthrough_sheet;
1448
1562
  }
1449
1563
 
@@ -1456,12 +1570,14 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1456
1570
 
1457
1571
  // Iterate through each selector group using rb_hash_foreach
1458
1572
  // to avoid rb_funcall in hot path
1573
+ VALUE old_to_new_id = rb_hash_new(); // Hash to track old rule IDs -> new merged rule IDs
1459
1574
  struct flatten_selectors_context merge_ctx;
1460
1575
  merge_ctx.merged_rules = merged_rules;
1461
1576
  merge_ctx.rules_array = rules_array;
1462
1577
  merge_ctx.rule_id_counter = &rule_id_counter;
1463
1578
  merge_ctx.selector_index = 0;
1464
1579
  merge_ctx.total_selectors = RHASH_SIZE(selector_groups);
1580
+ merge_ctx.old_to_new_id = old_to_new_id;
1465
1581
 
1466
1582
  DEBUG_PRINTF("\n=== Processing %ld selector groups ===\n", merge_ctx.total_selectors);
1467
1583
 
@@ -1512,15 +1628,40 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1512
1628
  }
1513
1629
  }
1514
1630
 
1515
- // Set @media_index to empty hash (no media rules after flatten)
1516
- // NOTE: Setting to empty hash instead of { all: [ids] } to match pure Ruby behavior
1517
- // and avoid wrapping output in @media all during serialization
1518
- VALUE media_idx = rb_hash_new();
1519
- rb_ivar_set(merged_sheet, id_ivar_media_index, media_idx);
1631
+ // Preserve media_index by remapping old rule IDs to new rule IDs
1632
+ // This is important for @media rules and @import statements with media constraints
1633
+ VALUE new_media_index = rb_hash_new();
1634
+
1635
+ if (!NIL_P(input_media_index) && TYPE(input_media_index) == T_HASH) {
1636
+ struct remap_media_context remap_ctx;
1637
+ remap_ctx.old_to_new_id = old_to_new_id;
1638
+ remap_ctx.new_media_index = new_media_index;
1639
+ rb_hash_foreach(input_media_index, remap_media_index_callback, (VALUE)&remap_ctx);
1640
+ }
1641
+
1642
+ rb_ivar_set(merged_sheet, id_ivar_media_index, new_media_index);
1643
+
1644
+ // Copy @media_queries and @_media_query_lists from input (these don't change during flatten)
1645
+ if (rb_obj_is_kind_of(input, cStylesheet)) {
1646
+ VALUE media_queries = rb_ivar_get(input, rb_intern("@media_queries"));
1647
+ VALUE media_query_lists = rb_ivar_get(input, rb_intern("@_media_query_lists"));
1648
+ Check_Type(media_queries, T_ARRAY);
1649
+ if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
1650
+ rb_ivar_set(merged_sheet, rb_intern("@media_queries"), media_queries);
1651
+ rb_ivar_set(merged_sheet, rb_intern("@_media_query_lists"), media_query_lists);
1652
+ }
1520
1653
 
1521
1654
  // Set @_selector_lists with divergence tracking
1522
1655
  rb_ivar_set(merged_sheet, rb_intern("@_selector_lists"), selector_lists);
1523
1656
 
1657
+ // Protect intermediate VALUEs from being collected
1658
+ RB_GC_GUARD(input_media_index);
1659
+ RB_GC_GUARD(rule_media_map);
1660
+ RB_GC_GUARD(selector_groups);
1661
+ RB_GC_GUARD(passthrough_rules);
1662
+ RB_GC_GUARD(old_to_new_id);
1663
+ RB_GC_GUARD(new_media_index);
1664
+
1524
1665
  return merged_sheet;
1525
1666
  }
1526
1667
 
@@ -1986,7 +2127,8 @@ VALUE cataract_flatten(VALUE self, VALUE input) {
1986
2127
  Qnil, // specificity (not applicable)
1987
2128
  Qnil, // parent_rule_id (not nested)
1988
2129
  Qnil, // nesting_style (not nested)
1989
- Qnil // selector_list_id
2130
+ Qnil, // selector_list_id
2131
+ Qnil // media_query_id
1990
2132
  );
1991
2133
 
1992
2134
  // Set @rules array with single merged rule (use cached ID)
@@ -882,6 +882,13 @@ VALUE cataract_create_background_shorthand(VALUE self, VALUE properties) {
882
882
  rb_str_append(result, size);
883
883
  }
884
884
 
885
+ // If all properties are defaults, the result would be empty
886
+ // In this case, use "none" which is equivalent to all-default background
887
+ if (RSTRING_LEN(result) == 0) {
888
+ DEBUG_PRINTF("[create_background_shorthand] All defaults omitted, using 'none'\n");
889
+ return USASCII_STR("none");
890
+ }
891
+
885
892
  DEBUG_PRINTF("[create_background_shorthand] result='%s'\n", RSTRING_PTR(result));
886
893
  return result;
887
894
  }
@@ -28,7 +28,8 @@ module Cataract
28
28
  # @attr [String] selector The at-rule identifier (e.g., "@keyframes fade", "@font-face")
29
29
  # @attr [Array<Rule>, Array<Declaration>] content Nested rules or declarations
30
30
  # @attr [nil] specificity Always nil for at-rules (they don't have CSS specificity)
31
- AtRule = Struct.new(:id, :selector, :content, :specificity) unless const_defined?(:AtRule)
31
+ # @attr [Integer, nil] media_query_id ID of MediaQuery if inside @media block, nil otherwise
32
+ AtRule = Struct.new(:id, :selector, :content, :specificity, :media_query_id) unless const_defined?(:AtRule)
32
33
 
33
34
  class AtRule
34
35
  # Check if this is a selector-based rule (vs an at-rule like @keyframes).
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cataract
4
+ # Default URI resolver proc for converting relative URLs to absolute
5
+ # Uses Ruby's URI stdlib to merge base and relative URIs
6
+ DEFAULT_URI_RESOLVER = lambda { |base, relative|
7
+ require 'uri'
8
+ URI.parse(base).merge(relative).to_s
9
+ }.freeze
10
+ end
@@ -20,7 +20,7 @@ module Cataract
20
20
  # @return [String] Fetched content
21
21
  # @raise [ImportError] If fetching fails
22
22
  def call(url, options)
23
- uri = ImportResolver.normalize_url(url, options[:base_path])
23
+ uri = ImportResolver.normalize_url(url, base_path: options[:base_path], base_uri: options[:base_uri])
24
24
 
25
25
  case uri.scheme
26
26
  when 'file'
@@ -56,6 +56,7 @@ module Cataract
56
56
  uri.open(open_uri_options, &:read)
57
57
  end
58
58
  end
59
+
59
60
  # Default options for safe import resolution
60
61
  SAFE_DEFAULTS = {
61
62
  max_depth: 5, # Prevent infinite recursion
@@ -63,81 +64,11 @@ module Cataract
63
64
  extensions: ['css'], # Only .css files
64
65
  timeout: 10, # 10 second timeout for fetches
65
66
  follow_redirects: true, # Follow redirects
66
- base_path: nil, # Base path for resolving relative imports
67
+ base_path: nil, # Base path for resolving relative file imports
68
+ base_uri: nil, # Base URI for resolving relative HTTP imports
67
69
  fetcher: nil # Custom fetcher (defaults to DefaultFetcher)
68
70
  }.freeze
69
71
 
70
- # Resolve @import statements in CSS
71
- #
72
- # @param css [String] CSS content with @import statements
73
- # @param options [Hash] Import resolution options
74
- # @option options [#call] :fetcher Custom fetcher callable (receives url, options)
75
- # @option options [Integer] :max_depth Maximum import nesting depth
76
- # @option options [Array<String>] :allowed_schemes Allowed URL schemes
77
- # @option options [Array<String>] :extensions Allowed file extensions
78
- # @option options [Integer] :timeout HTTP request timeout in seconds
79
- # @option options [Boolean] :follow_redirects Follow HTTP redirects
80
- # @option options [String] :base_path Base path for relative imports
81
- # @param depth [Integer] Current recursion depth (internal)
82
- # @param imported_urls [Array] Array of already imported URLs to prevent circular references
83
- # @return [String] CSS with imports inlined
84
- def self.resolve(css, options = {}, depth: 0, imported_urls: [])
85
- # Normalize options
86
- opts = normalize_options(options)
87
-
88
- # Get or create fetcher
89
- fetcher = opts[:fetcher] || DefaultFetcher.new
90
-
91
- # Check recursion depth
92
- # depth starts at 0, max_depth is count of imports allowed
93
- # depth 0: parsing main file (counts as import 1)
94
- # depth 1: parsing first @import (counts as import 2)
95
- # depth 2: parsing nested @import (counts as import 3)
96
- if depth > opts[:max_depth]
97
- raise ImportError, "Import nesting too deep: exceeded maximum depth of #{opts[:max_depth]}"
98
- end
99
-
100
- # Find all @import statements at the top of the file
101
- # Per CSS spec, @import must come before all rules except @charset
102
- imports = extract_imports(css)
103
-
104
- return css if imports.empty?
105
-
106
- # Process each import
107
- resolved_css = +'' # Mutable string
108
- remaining_css = css
109
-
110
- imports.each do |import_data|
111
- url = import_data[:url]
112
- media = import_data[:media]
113
-
114
- # Validate URL
115
- validate_url(url, opts)
116
-
117
- # Check for circular references
118
- raise ImportError, "Circular import detected: #{url}" if imported_urls.include?(url)
119
-
120
- # Fetch imported CSS using the fetcher
121
- imported_css = fetcher.call(url, opts)
122
-
123
- # Recursively resolve imports in the imported CSS
124
- imported_urls_copy = imported_urls.dup
125
- imported_urls_copy << url
126
- imported_css = resolve(imported_css, opts, depth: depth + 1, imported_urls: imported_urls_copy)
127
-
128
- # Wrap in @media if import had media query
129
- imported_css = "@media #{media} {\n#{imported_css}\n}" if media
130
-
131
- resolved_css << imported_css << "\n"
132
-
133
- # Remove this import from remaining CSS
134
- remaining_css = remaining_css.sub(import_data[:full_match], '')
135
- end
136
-
137
- # Return resolved imports + remaining CSS
138
- resolved_css + remaining_css
139
- end
140
-
141
72
  # Normalize options with safe defaults
142
73
  def self.normalize_options(options)
143
74
  if options == true
@@ -151,27 +82,27 @@ module Cataract
151
82
  end
152
83
  end
153
84
 
154
- # Extract @import statements from CSS
155
- # Returns array of hashes: { url: "...", media: "...", full_match: "..." }
156
- # Delegates to C implementation for performance
157
- def self.extract_imports(css)
158
- Cataract.extract_imports(css)
159
- end
160
-
161
85
  # Normalize URL - handle relative paths and missing schemes
162
86
  # Returns a URI object
163
- def self.normalize_url(url, base_path = nil)
87
+ #
88
+ # @param url [String] URL to normalize
89
+ # @param base_path [String, nil] Base path for resolving relative file imports
90
+ # @param base_uri [String, nil] Base URI for resolving relative HTTP imports
91
+ def self.normalize_url(url, base_path: nil, base_uri: nil)
164
92
  # Try to parse as-is first
165
93
  uri = URI.parse(url)
166
94
 
167
- # If no scheme, treat as relative file path
95
+ # If no scheme, treat as relative path
168
96
  if uri.scheme.nil?
169
- # Convert to file:// URL
170
- # Relative paths stay relative, absolute paths stay absolute
171
- if url.start_with?('/')
97
+ # If we have a base_uri (HTTP/HTTPS), resolve against it
98
+ if base_uri
99
+ base = URI.parse(base_uri)
100
+ uri = base.merge(url)
101
+ elsif url.start_with?('/')
102
+ # Absolute file path
172
103
  uri = URI.parse("file://#{url}")
173
104
  else
174
- # Relative path - make it absolute relative to base_path or current directory
105
+ # Relative file path - make it absolute relative to base_path or current directory
175
106
  absolute_path = if base_path
176
107
  File.expand_path(url, base_path)
177
108
  else
@@ -188,7 +119,7 @@ module Cataract
188
119
 
189
120
  # Validate URL against security options
190
121
  def self.validate_url(url, options)
191
- uri = normalize_url(url, options[:base_path])
122
+ uri = normalize_url(url, base_path: options[:base_path], base_uri: options[:base_uri])
192
123
 
193
124
  # Check scheme
194
125
  unless options[:allowed_schemes].include?(uri.scheme)