@aihu/css-engine 0.2.5 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +27 -22
  2. package/crates/aihu-css-core/src/apply.rs +314 -0
  3. package/crates/aihu-css-core/src/bin/main.rs +8 -7
  4. package/crates/aihu-css-core/src/cache.rs +8 -5
  5. package/crates/aihu-css-core/src/emit.rs +195 -36
  6. package/crates/aihu-css-core/src/lib.rs +15 -2
  7. package/crates/aihu-css-core/src/palette.rs +301 -0
  8. package/crates/aihu-css-core/src/style_parser.rs +587 -0
  9. package/crates/aihu-css-core/src/theme.rs +14 -0
  10. package/crates/aihu-css-core/src/tokens.rs +1196 -29
  11. package/crates/aihu-css-core/src/variants.rs +251 -3
  12. package/crates/aihu-css-core/tests/apply.rs +203 -0
  13. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  14. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  15. package/crates/aihu-css-core/tests/cache.rs +8 -8
  16. package/crates/aihu-css-core/tests/emit.rs +284 -17
  17. package/crates/aihu-css-core/tests/parity.rs +274 -0
  18. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  19. package/crates/aihu-css-core/tests/scoped_snapshot.rs +80 -8
  20. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  29. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +24 -0
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  43. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  44. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  45. package/crates/aihu-css-core/tests/tokens.rs +526 -7
  46. package/dist/index.d.ts +0 -9
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +26 -18
  49. package/dist/index.js.map +1 -1
  50. package/dist/runtime/cn.js +13 -0
  51. package/dist/runtime/cn.js.map +1 -1
  52. package/package.json +6 -6
@@ -20,6 +20,7 @@ expression: "compile_sfc_scoped(&sfc(\"bg-primary p-4 rounded-lg\"))"
20
20
  --color-surface: #faf8f4;
21
21
  --color-surface-foreground: #1a1d24;
22
22
  }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
23
24
  .bg-primary { background-color: var(--color-primary); }
24
25
  .p-4 { padding: 1rem; }
25
26
  .rounded-lg { border-radius: 0.5rem; }
@@ -0,0 +1,24 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ expression: "compile_sfc_scoped(&sfc(\"space-y-4\"))"
4
+ ---
5
+ :host {
6
+ --color-accent: #c8543a;
7
+ --color-accent-foreground: #faf8f4;
8
+ --color-background: #faf8f4;
9
+ --color-border: #ddd9d2;
10
+ --color-destructive: #a8432b;
11
+ --color-destructive-foreground: #faf8f4;
12
+ --color-foreground: #1a1d24;
13
+ --color-muted: #5a5a55;
14
+ --color-muted-foreground: #8a8880;
15
+ --color-primary: #1a1d24;
16
+ --color-primary-foreground: #faf8f4;
17
+ --color-ring: #c8543a;
18
+ --color-secondary: #5a5a55;
19
+ --color-secondary-foreground: #faf8f4;
20
+ --color-surface: #faf8f4;
21
+ --color-surface-foreground: #1a1d24;
22
+ }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
24
+ .space-y-4 { & > * + * { margin-block-start: 1rem; } }
@@ -0,0 +1,26 @@
1
+ ---
2
+ source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
+ expression: "compile_sfc_scoped(&sfc(\"transition-transform duration-300 hover:scale-105\"))"
4
+ ---
5
+ :host {
6
+ --color-accent: #c8543a;
7
+ --color-accent-foreground: #faf8f4;
8
+ --color-background: #faf8f4;
9
+ --color-border: #ddd9d2;
10
+ --color-destructive: #a8432b;
11
+ --color-destructive-foreground: #faf8f4;
12
+ --color-foreground: #1a1d24;
13
+ --color-muted: #5a5a55;
14
+ --color-muted-foreground: #8a8880;
15
+ --color-primary: #1a1d24;
16
+ --color-primary-foreground: #faf8f4;
17
+ --color-ring: #c8543a;
18
+ --color-secondary: #5a5a55;
19
+ --color-secondary-foreground: #faf8f4;
20
+ --color-surface: #faf8f4;
21
+ --color-surface-foreground: #1a1d24;
22
+ }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
24
+ .duration-300 { transition-duration: 300ms; }
25
+ .hover\:scale-105:hover { transform: scale(1.05); }
26
+ .transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
- expression: compile_sfc_scoped(&ast(json))
3
+ expression: compile_sfc_scoped(&ast(json)).unwrap()
4
4
  ---
5
5
  :host {
6
6
  --color-accent: #c8543a;
@@ -20,7 +20,11 @@ expression: compile_sfc_scoped(&ast(json))
20
20
  --color-surface: #faf8f4;
21
21
  --color-surface-foreground: #1a1d24;
22
22
  }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
23
24
  .p-4 { padding: 1rem; }
24
25
  .shadow-md { box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
25
26
  /* authored @style (scoped) */
26
- .inner { display: grid; gap: 1rem; }
27
+ .inner {
28
+ display: grid;
29
+ gap: 1rem;
30
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  source: packages/css-engine/crates/aihu-css-core/tests/scoped_snapshot.rs
3
- expression: compile_sfc_scoped(&ast(json))
3
+ expression: compile_sfc_scoped(&ast(json)).unwrap()
4
4
  ---
5
5
  :host {
6
6
  --color-accent: #c8543a;
@@ -20,5 +20,8 @@ expression: compile_sfc_scoped(&ast(json))
20
20
  --color-surface: #faf8f4;
21
21
  --color-surface-foreground: #1a1d24;
22
22
  }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
23
24
  /* authored @style ($global — unscoped) */
24
- body { margin: 0; }
25
+ body {
26
+ margin: 0;
27
+ }
@@ -20,6 +20,7 @@ expression: "compile_sfc_scoped(&sfc(\"hover:bg-primary focus:text-accent dark:b
20
20
  --color-surface: #faf8f4;
21
21
  --color-surface-foreground: #1a1d24;
22
22
  }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
23
24
  .\[&>div\]\:text-primary>div { color: var(--color-primary); }
24
25
  /* dark cascade (Firefox-safe; see decision-firefox-host-context-workaround) */
25
26
  :host([data-theme="dark"]) .dark\:bg-surface, :root.dark .dark\:bg-surface { background-color: var(--color-surface); }
@@ -21,6 +21,7 @@ expression: "format!(\"--- default ---\\n{default}\\n--- override ---\\n{overrid
21
21
  --color-surface: #faf8f4;
22
22
  --color-surface-foreground: #1a1d24;
23
23
  }
24
+ *, ::before, ::after { border-style: solid; border-width: 0; }
24
25
  .bg-primary { background-color: var(--color-primary); }
25
26
 
26
27
  --- override ---
@@ -42,4 +43,5 @@ expression: "format!(\"--- default ---\\n{default}\\n--- override ---\\n{overrid
42
43
  --color-surface: #faf8f4;
43
44
  --color-surface-foreground: #1a1d24;
44
45
  }
46
+ *, ::before, ::after { border-style: solid; border-width: 0; }
45
47
  .bg-primary { background-color: var(--color-primary); }
@@ -20,6 +20,7 @@ expression: "compile_sfc_scoped(&sfc(\"host:bg-primary slotted:p-4 slotted-img:r
20
20
  --color-surface: #faf8f4;
21
21
  --color-surface-foreground: #1a1d24;
22
22
  }
23
+ *, ::before, ::after { border-style: solid; border-width: 0; }
23
24
  /* dark cascade (Firefox-safe; see decision-firefox-host-context-workaround) */
24
25
  :host([data-theme="dark"]) .host-context-dark\:bg-surface, :root.dark .host-context-dark\:bg-surface { background-color: var(--color-surface); }
25
26
  :host(.host\:bg-primary) { background-color: var(--color-primary); }
@@ -0,0 +1,257 @@
1
+ //! Structured `@style`-rule parser tests (T2, R-SHARED-PARSER).
2
+ //!
3
+ //! This parser is the SINGLE source reused by `@apply` (T3) and variant
4
+ //! validation (PR-3), so the tests pin the public API both consumers depend on:
5
+ //! the rule tree, per-rule `@apply` directives, and each rule's selector
6
+ //! context. Codex flagged a naive string scanner as a trap, so the tests
7
+ //! deliberately stress comments, strings, arbitrary-value utilities, `;`-in-
8
+ //! values, and brace nesting.
9
+
10
+ use aihu_css_core::style_parser::{parse_style, StyleNode, StyleParseError};
11
+
12
+ /// Find the first top-level rule, asserting there is one.
13
+ fn first_rule(sheet: &aihu_css_core::StyleSheet) -> &aihu_css_core::StyleRule {
14
+ sheet
15
+ .nodes
16
+ .iter()
17
+ .find_map(|n| match n {
18
+ StyleNode::Rule(r) => Some(r),
19
+ _ => None,
20
+ })
21
+ .expect("expected at least one top-level rule")
22
+ }
23
+
24
+ #[test]
25
+ fn parses_a_simple_rule_with_declarations() {
26
+ let sheet = parse_style(".box { color: red; padding: 4px; }").unwrap();
27
+ let rule = first_rule(&sheet);
28
+ assert_eq!(rule.selector, ".box");
29
+ assert_eq!(rule.declarations.len(), 2);
30
+ assert_eq!(rule.declarations[0].prop, "color");
31
+ assert_eq!(rule.declarations[0].value, "red");
32
+ assert_eq!(rule.declarations[1].prop, "padding");
33
+ assert_eq!(rule.declarations[1].value, "4px");
34
+ assert!(rule.applies.is_empty());
35
+ }
36
+
37
+ #[test]
38
+ fn parses_multiple_top_level_rules() {
39
+ let sheet = parse_style(".a { color: red; } .b, .c { color: blue; }").unwrap();
40
+ let rules: Vec<_> = sheet
41
+ .nodes
42
+ .iter()
43
+ .filter_map(|n| match n {
44
+ StyleNode::Rule(r) => Some(r),
45
+ _ => None,
46
+ })
47
+ .collect();
48
+ assert_eq!(rules.len(), 2);
49
+ assert_eq!(rules[0].selector, ".a");
50
+ // Selector list preserved verbatim (validation/PR-3 needs the raw context).
51
+ assert_eq!(rules[1].selector, ".b, .c");
52
+ }
53
+
54
+ #[test]
55
+ fn captures_apply_directives_per_rule() {
56
+ let sheet = parse_style(".btn { @apply bg-primary p-4; color: red; @apply hover:bg-accent; }")
57
+ .unwrap();
58
+ let rule = first_rule(&sheet);
59
+ assert_eq!(rule.applies.len(), 2);
60
+ assert_eq!(rule.applies[0].tokens, vec!["bg-primary", "p-4"]);
61
+ assert_eq!(rule.applies[1].tokens, vec!["hover:bg-accent"]);
62
+ // The interleaved declaration is still captured.
63
+ assert_eq!(rule.declarations.len(), 1);
64
+ assert_eq!(rule.declarations[0].prop, "color");
65
+ }
66
+
67
+ #[test]
68
+ fn apply_keyword_is_not_confused_with_prefix() {
69
+ // `@applyfoo` is NOT an `@apply` directive — it stays an at-statement, not a
70
+ // parsed apply.
71
+ let sheet = parse_style(".x { @applyfoo bar; }").unwrap();
72
+ let rule = first_rule(&sheet);
73
+ assert!(rule.applies.is_empty(), "@applyfoo must not parse as @apply");
74
+ }
75
+
76
+ #[test]
77
+ fn is_comment_aware() {
78
+ // Braces, semicolons, and `@apply`-looking text inside comments must be
79
+ // ignored entirely.
80
+ let css = r#"
81
+ /* a comment with { braces } and ; and @apply fake; */
82
+ .a {
83
+ /* inner comment ; { } */
84
+ color: red; /* trailing */
85
+ }
86
+ "#;
87
+ let sheet = parse_style(css).unwrap();
88
+ let rule = first_rule(&sheet);
89
+ assert_eq!(rule.selector, ".a");
90
+ assert_eq!(rule.declarations.len(), 1);
91
+ assert_eq!(rule.declarations[0].prop, "color");
92
+ assert_eq!(rule.declarations[0].value, "red");
93
+ // The fake @apply inside the comment was NOT captured.
94
+ assert!(rule.applies.is_empty());
95
+ }
96
+
97
+ #[test]
98
+ fn is_string_aware_for_braces_and_semicolons() {
99
+ // `;` and `}` inside a quoted string must not terminate the declaration or
100
+ // rule.
101
+ let css = r#".q { content: "a; b } c"; color: red; }"#;
102
+ let sheet = parse_style(css).unwrap();
103
+ let rule = first_rule(&sheet);
104
+ assert_eq!(rule.declarations.len(), 2);
105
+ assert_eq!(rule.declarations[0].prop, "content");
106
+ assert_eq!(rule.declarations[0].value, r#""a; b } c""#);
107
+ assert_eq!(rule.declarations[1].prop, "color");
108
+ }
109
+
110
+ #[test]
111
+ fn semicolon_inside_url_value_does_not_split() {
112
+ // A `;` (and `:`) inside `url(...)` / data URI must stay in the value.
113
+ let css = r#".bg { background: url("data:image/svg+xml;base64,AAAA") center; }"#;
114
+ let sheet = parse_style(css).unwrap();
115
+ let rule = first_rule(&sheet);
116
+ assert_eq!(rule.declarations.len(), 1);
117
+ assert_eq!(rule.declarations[0].prop, "background");
118
+ assert_eq!(
119
+ rule.declarations[0].value,
120
+ r#"url("data:image/svg+xml;base64,AAAA") center"#
121
+ );
122
+ }
123
+
124
+ #[test]
125
+ fn arbitrary_value_apply_tokens_survive() {
126
+ // `bg-[#fff]` and `w-[calc(100%-1rem)]` must round-trip as single tokens.
127
+ let sheet = parse_style(".c { @apply bg-[#fff] w-[calc(100%-1rem)] p-4; }").unwrap();
128
+ let rule = first_rule(&sheet);
129
+ assert_eq!(
130
+ rule.applies[0].tokens,
131
+ vec!["bg-[#fff]", "w-[calc(100%-1rem)]", "p-4"]
132
+ );
133
+ }
134
+
135
+ #[test]
136
+ fn parses_nested_media_at_rule() {
137
+ let css = ".a { color: red; @media (min-width: 600px) { &:hover { color: blue; } } }";
138
+ let sheet = parse_style(css).unwrap();
139
+ let rule = first_rule(&sheet);
140
+ assert_eq!(rule.declarations.len(), 1);
141
+ assert_eq!(rule.nested.len(), 1);
142
+ match &rule.nested[0] {
143
+ StyleNode::AtRule(at) => {
144
+ assert_eq!(at.name, "@media");
145
+ assert_eq!(at.prelude, "(min-width: 600px)");
146
+ // The body has one nested rule.
147
+ match &at.body[0] {
148
+ StyleNode::Rule(inner) => {
149
+ assert_eq!(inner.selector, "&:hover");
150
+ assert_eq!(inner.declarations[0].prop, "color");
151
+ }
152
+ other => panic!("expected nested rule, got {other:?}"),
153
+ }
154
+ }
155
+ other => panic!("expected @media at-rule, got {other:?}"),
156
+ }
157
+ }
158
+
159
+ #[test]
160
+ fn parses_top_level_supports_and_container() {
161
+ let css = "@supports (display: grid) { .g { display: grid; } } \
162
+ @container (min-width: 20rem) { .c { color: red; } }";
163
+ let sheet = parse_style(css).unwrap();
164
+ let names: Vec<&str> = sheet
165
+ .nodes
166
+ .iter()
167
+ .filter_map(|n| match n {
168
+ StyleNode::AtRule(at) => Some(at.name.as_str()),
169
+ _ => None,
170
+ })
171
+ .collect();
172
+ assert_eq!(names, vec!["@supports", "@container"]);
173
+ }
174
+
175
+ #[test]
176
+ fn parses_nested_style_rule() {
177
+ // Native CSS nesting: a rule inside a rule (with `&`).
178
+ let css = ".card { color: red; &:hover { color: blue; } }";
179
+ let sheet = parse_style(css).unwrap();
180
+ let rule = first_rule(&sheet);
181
+ assert_eq!(rule.declarations.len(), 1);
182
+ assert_eq!(rule.nested.len(), 1);
183
+ match &rule.nested[0] {
184
+ StyleNode::Rule(inner) => assert_eq!(inner.selector, "&:hover"),
185
+ other => panic!("expected nested rule, got {other:?}"),
186
+ }
187
+ }
188
+
189
+ #[test]
190
+ fn data_attribute_selector_context_is_preserved() {
191
+ // PR-3 validation needs the raw selector to spot `[data-variant="x"]`.
192
+ let css = r#".btn[data-variant="primary"] { color: red; }"#;
193
+ let sheet = parse_style(css).unwrap();
194
+ let rule = first_rule(&sheet);
195
+ assert_eq!(rule.selector, r#".btn[data-variant="primary"]"#);
196
+ }
197
+
198
+ #[test]
199
+ fn colon_in_selector_is_not_a_declaration_split() {
200
+ // `:hover` / `:is(...)` in a selector must not be mistaken for a decl `:`.
201
+ let css = ":is(.a, .b):hover { color: red; }";
202
+ let sheet = parse_style(css).unwrap();
203
+ let rule = first_rule(&sheet);
204
+ assert_eq!(rule.selector, ":is(.a, .b):hover");
205
+ assert_eq!(rule.declarations[0].prop, "color");
206
+ }
207
+
208
+ #[test]
209
+ fn unchanged_block_round_trips_equivalently() {
210
+ // Parse → to_css → re-parse must yield the same rule tree (semantic round-
211
+ // trip; formatting is normalized by `to_css`).
212
+ let css = r#"
213
+ .a { color: red; padding: 4px; }
214
+ .b:hover { color: blue; @apply bg-primary p-4; }
215
+ @media (min-width: 600px) {
216
+ .a { color: green; }
217
+ }
218
+ "#;
219
+ let first = parse_style(css).unwrap();
220
+ let rendered = first.to_css();
221
+ let second = parse_style(&rendered).unwrap();
222
+ assert_eq!(
223
+ first, second,
224
+ "round-trip changed the tree:\n{rendered}"
225
+ );
226
+ }
227
+
228
+ #[test]
229
+ fn unbalanced_braces_error() {
230
+ let err = parse_style(".a { color: red; ").unwrap_err();
231
+ assert_eq!(err, StyleParseError::UnbalancedBraces);
232
+ }
233
+
234
+ #[test]
235
+ fn unterminated_comment_error() {
236
+ let err = parse_style(".a { /* never closed ").unwrap_err();
237
+ assert_eq!(err, StyleParseError::UnterminatedComment);
238
+ }
239
+
240
+ #[test]
241
+ fn unterminated_string_error() {
242
+ let err = parse_style(r#".a { content: "oops; }"#).unwrap_err();
243
+ assert_eq!(err, StyleParseError::UnterminatedString);
244
+ }
245
+
246
+ #[test]
247
+ fn empty_input_is_empty_sheet() {
248
+ let sheet = parse_style(" \n ").unwrap();
249
+ assert!(sheet.nodes.is_empty());
250
+ }
251
+
252
+ #[test]
253
+ fn stray_semicolons_are_ignored() {
254
+ let sheet = parse_style(";; .a { color: red; };").unwrap();
255
+ let rule = first_rule(&sheet);
256
+ assert_eq!(rule.selector, ".a");
257
+ }