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.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -3
- data/BENCHMARKS.md +32 -32
- data/CHANGELOG.md +13 -0
- data/Gemfile +3 -0
- data/ext/cataract/cataract.c +219 -112
- data/ext/cataract/cataract.h +5 -1
- data/ext/cataract/css_parser.c +523 -33
- data/ext/cataract/flatten.c +233 -91
- data/ext/cataract/shorthand_expander.c +7 -0
- data/lib/cataract/at_rule.rb +2 -1
- data/lib/cataract/constants.rb +10 -0
- data/lib/cataract/import_resolver.rb +18 -87
- data/lib/cataract/import_statement.rb +29 -5
- data/lib/cataract/media_query.rb +98 -0
- data/lib/cataract/pure/byte_constants.rb +11 -0
- data/lib/cataract/pure/flatten.rb +127 -15
- data/lib/cataract/pure/parser.rb +654 -270
- data/lib/cataract/pure/serializer.rb +216 -115
- data/lib/cataract/pure.rb +6 -7
- data/lib/cataract/rule.rb +9 -5
- data/lib/cataract/stylesheet.rb +321 -99
- data/lib/cataract/stylesheet_scope.rb +10 -7
- data/lib/cataract/version.rb +1 -1
- data/lib/cataract.rb +4 -8
- data/lib/tasks/profile.rake +210 -0
- metadata +4 -2
- data/lib/cataract/pure/imports.rb +0 -268
data/ext/cataract/flatten.c
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
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,
|
|
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,
|
|
1417
|
-
DEBUG_PRINTF(" -> Created new selector group for '%s'\n",
|
|
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
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
|
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
|
}
|
data/lib/cataract/at_rule.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
+
# If no scheme, treat as relative path
|
|
168
96
|
if uri.scheme.nil?
|
|
169
|
-
#
|
|
170
|
-
|
|
171
|
-
|
|
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)
|