cataract 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67046f0d371d7b0b077971e8634ff4054e64fe23a55c80b1a9f80e3dee6d804b
4
- data.tar.gz: 776865b7490e22c326c0bab93a3da32f088177817b3e237c68b66e233df39021
3
+ metadata.gz: 4f107809adb744598338be592626fc5ebbfdb81c2813957c7e5ed1652f37a607
4
+ data.tar.gz: 810a6aadbe0a8c2ce4eff7c307c17c9de354f1a3bc0d9509ae4e83f1d7e1c8b0
5
5
  SHA512:
6
- metadata.gz: 21f5ccd9b4ed3e0831fa8723d02493b7b4a3582c359a3e3bae927e554d207422a0139592b23889d273ca1ed3a1888679c729d9ad0f4aa2bf4c90d18b2e16fe0c
7
- data.tar.gz: f149256218337a47a1b27cd945857dd358199ceb47f70274c0c7db5cb3fa196d84a55281528c0f22d12da4bbbaff3b92c6e315210906a75aee2d304a9b91c275
6
+ metadata.gz: b7e85ed2f9ad0b9154e78c795bc0548ca1eaeb55f8f24b2b644852394a506173afb64042f55a3c02c13c21e1b539738695a83c8f774aaf0311bdda8f00da42b9
7
+ data.tar.gz: f6ea7361ad3d180aaf9e4faa72bf686906a0f81345df59f70856734e50f17b79d4dc20823d0fae520279767cd289fb8e42bbd619230f095d1d26f9ed95aa1094
data/.yardopts ADDED
@@ -0,0 +1,9 @@
1
+ --output-dir docs
2
+ --readme README.md
3
+ --title 'Cataract - Fast CSS Parser'
4
+ --exclude lib/cataract/pure\.rb
5
+ --exclude lib/cataract/pure/
6
+ lib/**/*.rb
7
+ ext/**/*.c
8
+ -
9
+ docs/files/EXAMPLE.md
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.5.0] - 2026-07-09
2
+
3
+ - Feature: `@supports (condition) { ... }` now preserves its condition and the rules it wraps through parse -> serialize, in both backends - previously the condition was discarded entirely and the wrapped rules were silently flattened into the surrounding document with no trace of ever being conditional.
4
+ - Feature: `@container name (condition) { ... }` (container queries) now preserves its name, condition, and wrapped rules the same way - handles named, anonymous, and name-only forms, and nests correctly with `@media`/`@supports`/itself.
5
+ - Feature: `@layer` (cascade layers) now preserves its name and wrapped rules, both in block form (`@layer name { ... }`, including anonymous and dotted/nested names like `framework.layout`) and statement form (`@layer a, b;`, which declares layer order with no wrapped rules at all - previously silently corrupted parsing of whatever rule came right after it).
6
+ - Fix: `@media`/`@supports`/`@container`/`@scope`/`@layer` with no whitespace before a following `(` (e.g. minified `@supports(display:grid){...}`) no longer misparses the at-rule name, which silently corrupted the rest of the block. Present in both backends since `@media`/`@supports` shipped.
7
+ - Feature: `@namespace` (default and prefixed, e.g. `@namespace svg url(http://www.w3.org/2000/svg);`) is now preserved through parse -> serialize, in both backends - previously it wasn't modelled at all. Namespaced selectors (`ns|E`, `*|E`, `|E`, and the attribute-selector equivalents) already parsed as opaque text, but their specificity was miscounted - a namespace prefix was counted as its own type selector instead of contributing nothing, e.g. `svg|rect` computed as specificity 2 instead of 1. Both are fixed now.
8
+
1
9
  ## [0.4.0] - 2026-07-08
2
10
 
3
11
  - Fix: `Declarations.new(some_string)` (standalone CSS declaration-block parsing) now works under the pure Ruby backend (`CATARACT_PURE=1`) - previously raised `NoMethodError`.
@@ -9,6 +9,7 @@ VALUE cAtRule;
9
9
  VALUE cStylesheet;
10
10
  VALUE cImportStatement;
11
11
  VALUE cMediaQuery;
12
+ VALUE cConditionalGroup;
12
13
 
13
14
  // Error class definitions (shared with main extension)
14
15
  VALUE eCataractError;
@@ -168,6 +169,13 @@ static void serialize_at_rule(VALUE result, VALUE at_rule) {
168
169
  VALUE selector = rb_struct_aref(at_rule, INT2FIX(AT_RULE_SELECTOR));
169
170
  VALUE content = rb_struct_aref(at_rule, INT2FIX(AT_RULE_CONTENT));
170
171
 
172
+ if (NIL_P(content)) {
173
+ // Statement form (e.g. `@layer a, b;`) - no block to wrap.
174
+ rb_str_append(result, selector);
175
+ rb_str_cat2(result, ";\n");
176
+ return;
177
+ }
178
+
171
179
  rb_str_append(result, selector);
172
180
  rb_str_cat2(result, " {\n");
173
181
 
@@ -277,6 +285,14 @@ static void serialize_at_rule_formatted(VALUE result, VALUE at_rule, const char
277
285
  VALUE selector = rb_struct_aref(at_rule, INT2FIX(AT_RULE_SELECTOR));
278
286
  VALUE content = rb_struct_aref(at_rule, INT2FIX(AT_RULE_CONTENT));
279
287
 
288
+ if (NIL_P(content)) {
289
+ // Statement form (e.g. `@layer a, b;`) - no block to wrap.
290
+ rb_str_cat2(result, indent);
291
+ rb_str_append(result, selector);
292
+ rb_str_cat2(result, ";\n");
293
+ return;
294
+ }
295
+
280
296
  rb_str_cat2(result, indent);
281
297
  rb_str_append(result, selector);
282
298
  rb_str_cat2(result, " {\n");
@@ -408,6 +424,77 @@ static VALUE build_rules_by_id(VALUE rules_array) {
408
424
  return rules_by_id;
409
425
  }
410
426
 
427
+ // A rule's conditional-group wrapping chain, outermost first, found by
428
+ // walking parent_id from its own conditional_group_id up to the root. Only
429
+ // @supports currently populates conditional_group_id (see the parser), but
430
+ // this walks whatever's there generically.
431
+ static VALUE conditional_group_chain(VALUE conditional_groups, VALUE conditional_group_id) {
432
+ VALUE chain = rb_ary_new();
433
+ VALUE id = conditional_group_id;
434
+ while (!NIL_P(id)) {
435
+ VALUE group = rb_ary_entry(conditional_groups, FIX2INT(id));
436
+ if (NIL_P(group)) break;
437
+
438
+ rb_ary_unshift(chain, group);
439
+ id = rb_struct_aref(group, INT2FIX(CONDITIONAL_GROUP_PARENT_ID));
440
+ }
441
+ return chain;
442
+ }
443
+
444
+ // Reconcile the currently-open conditional-group blocks with the ones a
445
+ // rule needs, like diffing two paths down a tree: close whatever's open
446
+ // past where the chains diverge (innermost first), then open whatever the
447
+ // target chain adds from that point on. Nested entirely inside whatever
448
+ // media block is currently open (conditional groups don't affect media
449
+ // context). Returns target_chain, for the caller to track as current.
450
+ static VALUE sync_conditional_group_chain(VALUE result, VALUE current_chain, VALUE target_chain, const char *indent) {
451
+ long current_len = RARRAY_LEN(current_chain);
452
+ long target_len = RARRAY_LEN(target_chain);
453
+ long common = 0;
454
+ while (common < current_len && common < target_len) {
455
+ VALUE cur_id = rb_struct_aref(rb_ary_entry(current_chain, common), INT2FIX(CONDITIONAL_GROUP_ID));
456
+ VALUE tgt_id = rb_struct_aref(rb_ary_entry(target_chain, common), INT2FIX(CONDITIONAL_GROUP_ID));
457
+ if (!rb_equal(cur_id, tgt_id)) break;
458
+
459
+ common++;
460
+ }
461
+
462
+ for (long i = current_len - 1; i >= common; i--) {
463
+ rb_str_cat2(result, indent);
464
+ rb_str_cat2(result, "}\n");
465
+ }
466
+
467
+ for (long i = common; i < target_len; i++) {
468
+ VALUE group = rb_ary_entry(target_chain, i);
469
+ VALUE name = rb_struct_aref(group, INT2FIX(CONDITIONAL_GROUP_NAME));
470
+ VALUE condition = rb_struct_aref(group, INT2FIX(CONDITIONAL_GROUP_CONDITION));
471
+
472
+ rb_str_cat2(result, indent);
473
+ rb_str_cat2(result, "@");
474
+ rb_str_append(result, rb_sym2str(rb_struct_aref(group, INT2FIX(CONDITIONAL_GROUP_TYPE))));
475
+ if (!NIL_P(name) || !NIL_P(condition)) {
476
+ rb_str_cat2(result, " ");
477
+ if (!NIL_P(name)) {
478
+ rb_str_append(result, name);
479
+ if (!NIL_P(condition)) rb_str_cat2(result, " ");
480
+ }
481
+ if (!NIL_P(condition)) {
482
+ rb_str_append(result, condition);
483
+ }
484
+ }
485
+ rb_str_cat2(result, " {\n");
486
+ }
487
+
488
+ return target_chain;
489
+ }
490
+
491
+ static void close_conditional_groups(VALUE result, long count, const char *indent) {
492
+ for (long i = 0; i < count; i++) {
493
+ rb_str_cat2(result, indent);
494
+ rb_str_cat2(result, "}\n");
495
+ }
496
+ }
497
+
411
498
  // Rule and AtRule don't share a member layout past their first few fields
412
499
  // (see the AT_RULE_* comment in cataract.h), so reading parent_rule_id or
413
500
  // media_query_id off a rule of unknown type needs to dispatch on which
@@ -425,6 +512,12 @@ static inline VALUE rule_media_query_id_of(VALUE rule) {
425
512
  : rb_struct_aref(rule, INT2FIX(RULE_MEDIA_QUERY_ID));
426
513
  }
427
514
 
515
+ static inline VALUE rule_conditional_group_id_of(VALUE rule) {
516
+ return rb_obj_is_kind_of(rule, cAtRule)
517
+ ? rb_struct_aref(rule, INT2FIX(AT_RULE_CONDITIONAL_GROUP_ID))
518
+ : rb_struct_aref(rule, INT2FIX(RULE_CONDITIONAL_GROUP_ID));
519
+ }
520
+
428
521
  // Formatting options for stylesheet serialization
429
522
  // Avoids mode flags and if/else branches - all behavior controlled by struct values
430
523
  struct format_opts {
@@ -564,6 +657,7 @@ static VALUE serialize_stylesheet_with_grouping(
564
657
  VALUE media_query_lists,
565
658
  VALUE result,
566
659
  VALUE selector_lists,
660
+ VALUE conditional_groups,
567
661
  const struct format_opts *opts
568
662
  ) {
569
663
  long total_rules = RARRAY_LEN(rules_array);
@@ -589,6 +683,7 @@ static VALUE serialize_stylesheet_with_grouping(
589
683
  // Iterate through rules in insertion order, grouping consecutive media queries
590
684
  VALUE current_media = Qnil;
591
685
  int in_media_block = 0;
686
+ VALUE current_cg_chain = rb_ary_new();
592
687
 
593
688
  for (long i = 0; i < total_rules; i++) {
594
689
  VALUE rule = rb_ary_entry(rules_array, i);
@@ -606,10 +701,12 @@ static VALUE serialize_stylesheet_with_grouping(
606
701
  rule_media = rb_ary_entry(media_queries, FIX2INT(rule_media_query_id));
607
702
  }
608
703
  int is_first_rule = (i == 0);
704
+ VALUE rule_cg_chain = conditional_group_chain(conditional_groups, rule_conditional_group_id_of(rule));
609
705
 
610
706
  if (NIL_P(rule_media)) {
611
707
  // Not in any media query - close any open media block first
612
708
  if (in_media_block) {
709
+ current_cg_chain = sync_conditional_group_chain(result, current_cg_chain, rb_ary_new(), opts->media_indent);
613
710
  rb_str_cat2(result, "}\n");
614
711
  in_media_block = 0;
615
712
  current_media = Qnil;
@@ -620,11 +717,15 @@ static VALUE serialize_stylesheet_with_grouping(
620
717
  rb_str_cat2(result, "\n");
621
718
  }
622
719
 
623
- serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 0);
720
+ current_cg_chain = sync_conditional_group_chain(result, current_cg_chain, rule_cg_chain, "");
721
+
722
+ serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, RARRAY_LEN(current_cg_chain) > 0);
624
723
  } else {
625
724
  // This rule is in a media query
626
725
  // Check if media query changed from previous rule (compare MediaQuery objects by value)
627
726
  if (NIL_P(current_media) || !rb_equal(current_media, rule_media)) {
727
+ current_cg_chain = sync_conditional_group_chain(result, current_cg_chain, rb_ary_new(), opts->media_indent);
728
+
628
729
  // Close previous media block if open
629
730
  if (in_media_block) {
630
731
  rb_str_cat2(result, "}\n");
@@ -648,19 +749,23 @@ static VALUE serialize_stylesheet_with_grouping(
648
749
  in_media_block = 1;
649
750
  }
650
751
 
752
+ current_cg_chain = sync_conditional_group_chain(result, current_cg_chain, rule_cg_chain, opts->media_indent);
753
+
651
754
  serialize_rule_in_group(result, rule, rule_id, rule_media_query_id, &group_ctx, 1);
652
755
  }
653
756
  }
654
757
 
655
- // Close final media block if still open
758
+ // Close final conditional-group and media blocks if still open
759
+ close_conditional_groups(result, RARRAY_LEN(current_cg_chain), in_media_block ? opts->media_indent : "");
656
760
  if (in_media_block) {
657
761
  rb_str_cat2(result, "}\n");
658
762
  }
659
763
 
660
- // Guard hash objects we created and used throughout
764
+ // Guard hash/array objects we created and used throughout
661
765
  RB_GC_GUARD(mq_id_to_list_id);
662
766
  RB_GC_GUARD(rules_by_id);
663
767
  RB_GC_GUARD(processed_rule_ids);
768
+ RB_GC_GUARD(current_cg_chain);
664
769
  return result;
665
770
  }
666
771
 
@@ -1010,11 +1115,12 @@ static VALUE serialize_stylesheet_with_nesting(
1010
1115
  // this, differing only in which format_opts they select. Dispatches on has_nesting
1011
1116
  // to pick the selector-list-grouping serializer (no nesting - zero overhead) or the
1012
1117
  // parent/child lookahead serializer (has nesting).
1013
- static VALUE stylesheet_serialize_impl(VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists, int formatted) {
1118
+ static VALUE stylesheet_serialize_impl(VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists, VALUE conditional_groups, int formatted) {
1014
1119
  Check_Type(rules_array, T_ARRAY);
1015
1120
  Check_Type(media_queries, T_ARRAY);
1016
1121
  if (!NIL_P(media_query_lists)) Check_Type(media_query_lists, T_HASH);
1017
1122
  if (!NIL_P(selector_lists)) Check_Type(selector_lists, T_HASH);
1123
+ if (!NIL_P(conditional_groups)) Check_Type(conditional_groups, T_ARRAY);
1018
1124
 
1019
1125
  VALUE result = rb_str_new_cstr("");
1020
1126
 
@@ -1048,22 +1154,23 @@ static VALUE stylesheet_serialize_impl(VALUE rules_array, VALUE charset, VALUE h
1048
1154
 
1049
1155
  // Fast path: if no nesting, use the selector-list-grouping serializer (zero overhead)
1050
1156
  if (!RTEST(has_nesting)) {
1051
- return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, &opts);
1157
+ return serialize_stylesheet_with_grouping(rules_array, media_queries, media_query_lists, result, selector_lists, conditional_groups, &opts);
1052
1158
  }
1053
1159
 
1054
1160
  // Has nesting - use the parent/child lookahead serializer
1055
1161
  // TODO: Phase 2 - use selector_lists for grouping on the nesting path too
1162
+ // TODO: conditional_groups (@supports etc.) aren't reconstructed on this path yet
1056
1163
  return serialize_stylesheet_with_nesting(rules_array, media_queries, media_query_lists, result, &opts);
1057
1164
  }
1058
1165
 
1059
1166
  // New stylesheet serialization entry point - checks for nesting and delegates
1060
- static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
1061
- return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 0);
1167
+ static VALUE stylesheet_to_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists, VALUE conditional_groups) {
1168
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, conditional_groups, 0);
1062
1169
  }
1063
1170
 
1064
1171
  // Formatted version with indentation and newlines (with nesting support)
1065
- static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists) {
1066
- return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, 1);
1172
+ static VALUE stylesheet_to_formatted_s(VALUE self, VALUE rules_array, VALUE charset, VALUE has_nesting, VALUE selector_lists, VALUE media_queries, VALUE media_query_lists, VALUE conditional_groups) {
1173
+ return stylesheet_serialize_impl(rules_array, charset, has_nesting, selector_lists, media_queries, media_query_lists, conditional_groups, 1);
1067
1174
  }
1068
1175
 
1069
1176
  /*
@@ -1207,6 +1314,12 @@ void Init_native_extension(void) {
1207
1314
  rb_raise(rb_eLoadError, "Cataract::MediaQuery not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1208
1315
  }
1209
1316
 
1317
+ if (rb_const_defined(mCataract, rb_intern("ConditionalGroup"))) {
1318
+ cConditionalGroup = rb_const_get(mCataract, rb_intern("ConditionalGroup"));
1319
+ } else {
1320
+ rb_raise(rb_eLoadError, "Cataract::ConditionalGroup not defined. Do not require 'cataract/native_extension' directly, use require 'cataract'");
1321
+ }
1322
+
1210
1323
  // Define Stylesheet class (Ruby will add instance methods like each_selector).
1211
1324
  // Declarations is left alone here - it's a plain Ruby class (declarations.rb)
1212
1325
  // with its own #to_s, not something this backend attaches methods to.
@@ -1219,8 +1332,8 @@ void Init_native_extension(void) {
1219
1332
  VALUE mNative = rb_define_module_under(mBackends, "Native");
1220
1333
 
1221
1334
  rb_define_module_function(mNative, "parse", parse_css_new, -1);
1222
- rb_define_module_function(mNative, "stylesheet_to_s", stylesheet_to_s, 6);
1223
- rb_define_module_function(mNative, "stylesheet_to_formatted_s", stylesheet_to_formatted_s, 6);
1335
+ rb_define_module_function(mNative, "stylesheet_to_s", stylesheet_to_s, 7);
1336
+ rb_define_module_function(mNative, "stylesheet_to_formatted_s", stylesheet_to_formatted_s, 7);
1224
1337
  rb_define_module_function(mNative, "parse_declarations", new_parse_declarations, 1);
1225
1338
  rb_define_module_function(mNative, "flatten", cataract_flatten, 1);
1226
1339
  rb_define_module_function(mNative, "calculate_specificity", calculate_specificity, 1);
@@ -14,6 +14,7 @@ extern VALUE cAtRule;
14
14
  extern VALUE cStylesheet;
15
15
  extern VALUE cImportStatement;
16
16
  extern VALUE cMediaQuery;
17
+ extern VALUE cConditionalGroup;
17
18
 
18
19
  // Error class references
19
20
  extern VALUE eCataractError;
@@ -25,7 +26,7 @@ extern VALUE eParseError;
25
26
  // Struct field indices
26
27
  // ============================================================================
27
28
 
28
- // Rule struct field indices (id, selector, declarations, specificity, parent_rule_id, nesting_style, selector_list_id, media_query_id)
29
+ // Rule struct field indices (id, selector, declarations, specificity, parent_rule_id, nesting_style, selector_list_id, media_query_id, conditional_group_id)
29
30
  #define RULE_ID 0
30
31
  #define RULE_SELECTOR 1
31
32
  #define RULE_DECLARATIONS 2
@@ -34,6 +35,7 @@ extern VALUE eParseError;
34
35
  #define RULE_NESTING_STYLE 5
35
36
  #define RULE_SELECTOR_LIST_ID 6
36
37
  #define RULE_MEDIA_QUERY_ID 7
38
+ #define RULE_CONDITIONAL_GROUP_ID 8
37
39
 
38
40
  // Nesting style constants
39
41
  #define NESTING_STYLE_IMPLICIT 0 // .parent { .child { } } - no &
@@ -45,13 +47,14 @@ extern VALUE eParseError;
45
47
  #define NO_PARENT_SELECTOR Qnil
46
48
  #define NO_PARENT_RULE_ID Qnil
47
49
  #define NO_MEDIA_QUERY_ID (-1)
50
+ #define NO_CONDITIONAL_GROUP_ID (-1)
48
51
 
49
52
  // Declaration struct field indices (property, value, important)
50
53
  #define DECL_PROPERTY 0
51
54
  #define DECL_VALUE 1
52
55
  #define DECL_IMPORTANT 2
53
56
 
54
- // AtRule struct field indices (id, selector, content, specificity, media_query_id)
57
+ // AtRule struct field indices (id, selector, content, specificity, media_query_id, conditional_group_id)
55
58
  // Matches Rule interface for duck-typing, but AtRule has fewer members - its
56
59
  // indices do NOT line up with Rule's past AT_RULE_SPECIFICITY (e.g. index 4
57
60
  // is media_query_id here but parent_rule_id on Rule), so never read a field
@@ -61,6 +64,14 @@ extern VALUE eParseError;
61
64
  #define AT_RULE_CONTENT 2
62
65
  #define AT_RULE_SPECIFICITY 3
63
66
  #define AT_RULE_MEDIA_QUERY_ID 4
67
+ #define AT_RULE_CONDITIONAL_GROUP_ID 5
68
+
69
+ // ConditionalGroup struct field indices (id, type, name, condition, parent_id)
70
+ #define CONDITIONAL_GROUP_ID 0
71
+ #define CONDITIONAL_GROUP_TYPE 1
72
+ #define CONDITIONAL_GROUP_NAME 2
73
+ #define CONDITIONAL_GROUP_CONDITION 3
74
+ #define CONDITIONAL_GROUP_PARENT_ID 4
64
75
 
65
76
  // ============================================================================
66
77
  // Macros