@aihu/css-engine 0.3.0 → 0.4.1
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.
- package/README.md +11 -11
- package/crates/aihu-css-core/src/apply.rs +314 -0
- package/crates/aihu-css-core/src/bin/main.rs +8 -7
- package/crates/aihu-css-core/src/cache.rs +8 -5
- package/crates/aihu-css-core/src/emit.rs +110 -30
- package/crates/aihu-css-core/src/lib.rs +10 -2
- package/crates/aihu-css-core/src/palette.rs +301 -0
- package/crates/aihu-css-core/src/style_parser.rs +587 -0
- package/crates/aihu-css-core/src/tokens.rs +625 -29
- package/crates/aihu-css-core/src/variants.rs +154 -7
- package/crates/aihu-css-core/tests/apply.rs +203 -0
- package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
- package/crates/aihu-css-core/tests/binary_error.rs +61 -0
- package/crates/aihu-css-core/tests/cache.rs +8 -8
- package/crates/aihu-css-core/tests/emit.rs +95 -36
- package/crates/aihu-css-core/tests/parity.rs +274 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
- package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
- package/crates/aihu-css-core/tests/style_parser.rs +257 -0
- package/crates/aihu-css-core/tests/tokens.rs +52 -0
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
|
@@ -32,8 +32,13 @@ pub enum Variant {
|
|
|
32
32
|
HostContextDark,
|
|
33
33
|
|
|
34
34
|
// ── standard ─────────────────────────────────────────────────────────────
|
|
35
|
-
/// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:`
|
|
35
|
+
/// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:`, `first:`
|
|
36
|
+
/// (→ `:first-child`), `last:`, `open:` → `&:<pc>`.
|
|
36
37
|
Pseudo(String),
|
|
38
|
+
/// `marker:` → `&::marker`, `placeholder:` → `&::placeholder`,
|
|
39
|
+
/// `before:`/`after:`/`selection:`/`file:` → `&::<pe>`. Pseudo-*element*
|
|
40
|
+
/// variants attach with a double colon (distinct from [`Variant::Pseudo`]).
|
|
41
|
+
PseudoElement(String),
|
|
37
42
|
/// `dark:` — dark cascade (NOT `:host-context()`).
|
|
38
43
|
Dark,
|
|
39
44
|
/// `sm:`/`md:`/`lg:`/`xl:`/`2xl:` → `@media (min-width: …)`.
|
|
@@ -145,13 +150,25 @@ fn parse_prefix(prefix: &str) -> Option<Variant> {
|
|
|
145
150
|
"host-context-dark" => Variant::HostContextDark,
|
|
146
151
|
"slotted" => Variant::Slotted,
|
|
147
152
|
"dark" => Variant::Dark,
|
|
148
|
-
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
|
|
149
|
-
| "
|
|
153
|
+
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited" | "checked"
|
|
154
|
+
| "required" | "open" => Variant::Pseudo(prefix.to_string()),
|
|
155
|
+
// Structural pseudo-classes map to their `:…-child` / `:nth-child()` form.
|
|
156
|
+
"first" => Variant::Pseudo("first-child".to_string()),
|
|
157
|
+
"last" => Variant::Pseudo("last-child".to_string()),
|
|
158
|
+
"only" => Variant::Pseudo("only-child".to_string()),
|
|
159
|
+
"odd" => Variant::Pseudo("nth-child(odd)".to_string()),
|
|
160
|
+
"even" => Variant::Pseudo("nth-child(even)".to_string()),
|
|
161
|
+
"empty" => Variant::Pseudo("empty".to_string()),
|
|
162
|
+
// Pseudo-elements (double colon).
|
|
163
|
+
"marker" => Variant::PseudoElement("marker".to_string()),
|
|
164
|
+
"placeholder" => Variant::PseudoElement("placeholder".to_string()),
|
|
165
|
+
"before" => Variant::PseudoElement("before".to_string()),
|
|
166
|
+
"after" => Variant::PseudoElement("after".to_string()),
|
|
167
|
+
"selection" => Variant::PseudoElement("selection".to_string()),
|
|
168
|
+
"file" => Variant::PseudoElement("file-selector-button".to_string()),
|
|
150
169
|
"sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
|
|
151
170
|
// Container-query breakpoints: `@sm`/`@md`/`@lg`/`@xl`/`@2xl`.
|
|
152
|
-
"@sm" | "@md" | "@lg" | "@xl" | "@2xl" =>
|
|
153
|
-
Variant::Container(prefix[1..].to_string())
|
|
154
|
-
}
|
|
171
|
+
"@sm" | "@md" | "@lg" | "@xl" | "@2xl" => Variant::Container(prefix[1..].to_string()),
|
|
155
172
|
_ => {
|
|
156
173
|
if let Some(tag) = prefix.strip_prefix("slotted-") {
|
|
157
174
|
Variant::SlottedTag(tag.to_string())
|
|
@@ -193,11 +210,141 @@ fn peer_state(prefix: &str) -> Option<Option<String>> {
|
|
|
193
210
|
fn relational_state(state: &str) -> Option<String> {
|
|
194
211
|
matches!(
|
|
195
212
|
state,
|
|
196
|
-
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked"
|
|
213
|
+
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked" | "open"
|
|
197
214
|
)
|
|
198
215
|
.then(|| state.to_string())
|
|
199
216
|
}
|
|
200
217
|
|
|
218
|
+
/// How a (possibly empty) variant list resolves against a base selector for the
|
|
219
|
+
/// `@apply` expansion path (`apply.rs`). This is the SHARED structural resolver
|
|
220
|
+
/// (R-APPLY-PARSE): `@apply hover:bg-accent` inside a `.btn { … }` rule must map
|
|
221
|
+
/// to a nested `&:hover { … }` block on the *recipe's own* selector — NOT to a
|
|
222
|
+
/// `.hover\:bg-accent:hover` class rule (Codex confirmed `emit_token` +
|
|
223
|
+
/// string-strip produces wrong output).
|
|
224
|
+
///
|
|
225
|
+
/// The emitter ([`emit_token`](crate::emit)) starts from a class selector
|
|
226
|
+
/// (`.token`); the `@apply` path starts from `&` (the parent rule). Both walk the
|
|
227
|
+
/// same variant arms, so the per-variant selector transforms live here as
|
|
228
|
+
/// [`apply_variant_to_selector`] and the wrapping/cascade decisions in
|
|
229
|
+
/// [`ResolvedVariants`].
|
|
230
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
231
|
+
pub struct ResolvedVariants {
|
|
232
|
+
/// The selector the declarations attach to, with each variant applied
|
|
233
|
+
/// against the starting base (`&` for `@apply`). E.g. `&:hover`,
|
|
234
|
+
/// `&[data-state="open"]`, `.group:hover &`.
|
|
235
|
+
pub selector: String,
|
|
236
|
+
/// A wrapping at-rule (`@media (min-width: …)` / `@container (…)`) the rule
|
|
237
|
+
/// nests inside, if a breakpoint/container variant was present.
|
|
238
|
+
pub at_rule: Option<String>,
|
|
239
|
+
/// True when a dark-cascade variant (`dark:`/`host-context-dark:`) was
|
|
240
|
+
/// present — the caller emits the Firefox-safe dark gate instead of a plain
|
|
241
|
+
/// rule.
|
|
242
|
+
pub dark_cascade: bool,
|
|
243
|
+
/// True when any variant implies host/`&`/relational scoping. Used to reject
|
|
244
|
+
/// such variants inside a `$global` `@apply` (Task 1.4 — variants that imply
|
|
245
|
+
/// `&`/host scoping are rejected in `$global`; base utilities allowed).
|
|
246
|
+
pub needs_scope: bool,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Apply one [`Variant`] to a running selector, mirroring the emitter's arms but
|
|
250
|
+
/// starting from an arbitrary base (`&` for the `@apply` path). Returns the new
|
|
251
|
+
/// selector, plus optional `(at_rule)` / `dark_cascade` / `needs_scope` signals
|
|
252
|
+
/// via the accumulator the caller threads.
|
|
253
|
+
fn apply_variant_to_selector(
|
|
254
|
+
v: &Variant,
|
|
255
|
+
selector: String,
|
|
256
|
+
theme: &crate::theme::ThemeRegistry,
|
|
257
|
+
acc: &mut ResolvedVariants,
|
|
258
|
+
) -> String {
|
|
259
|
+
match v {
|
|
260
|
+
Variant::Host => {
|
|
261
|
+
acc.needs_scope = true;
|
|
262
|
+
format!(":host({selector})")
|
|
263
|
+
}
|
|
264
|
+
Variant::Slotted => {
|
|
265
|
+
acc.needs_scope = true;
|
|
266
|
+
format!("::slotted({selector})")
|
|
267
|
+
}
|
|
268
|
+
Variant::SlottedTag(tag) => {
|
|
269
|
+
acc.needs_scope = true;
|
|
270
|
+
format!("::slotted({tag}{selector})")
|
|
271
|
+
}
|
|
272
|
+
Variant::Part(name) => {
|
|
273
|
+
acc.needs_scope = true;
|
|
274
|
+
format!("::part({name})")
|
|
275
|
+
}
|
|
276
|
+
Variant::Pseudo(pc) => {
|
|
277
|
+
acc.needs_scope = true;
|
|
278
|
+
format!("{selector}:{pc}")
|
|
279
|
+
}
|
|
280
|
+
Variant::PseudoElement(pe) => {
|
|
281
|
+
acc.needs_scope = true;
|
|
282
|
+
format!("{selector}::{pe}")
|
|
283
|
+
}
|
|
284
|
+
Variant::ArbitrarySelector(sel) => {
|
|
285
|
+
acc.needs_scope = true;
|
|
286
|
+
sel.replace('&', &selector)
|
|
287
|
+
}
|
|
288
|
+
Variant::Group(Some(state)) => {
|
|
289
|
+
acc.needs_scope = true;
|
|
290
|
+
format!(".group:{state} {selector}")
|
|
291
|
+
}
|
|
292
|
+
Variant::Peer(Some(state)) => {
|
|
293
|
+
acc.needs_scope = true;
|
|
294
|
+
format!(".peer:{state} ~ {selector}")
|
|
295
|
+
}
|
|
296
|
+
Variant::Group(None) | Variant::Peer(None) => selector,
|
|
297
|
+
Variant::Aria(m) => {
|
|
298
|
+
acc.needs_scope = true;
|
|
299
|
+
format!("{selector}{}", crate::emit::attr_selector("aria", m))
|
|
300
|
+
}
|
|
301
|
+
Variant::Data(m) => {
|
|
302
|
+
acc.needs_scope = true;
|
|
303
|
+
format!("{selector}{}", crate::emit::attr_selector("data", m))
|
|
304
|
+
}
|
|
305
|
+
Variant::Breakpoint(bp) => {
|
|
306
|
+
if let Some(min) = theme.breakpoint(bp) {
|
|
307
|
+
acc.at_rule = Some(format!("@media (min-width: {min})"));
|
|
308
|
+
}
|
|
309
|
+
selector
|
|
310
|
+
}
|
|
311
|
+
Variant::Container(bp) => {
|
|
312
|
+
if let Some(min) = theme.container_breakpoint(bp) {
|
|
313
|
+
acc.at_rule = Some(format!("@container (min-width: {min})"));
|
|
314
|
+
}
|
|
315
|
+
selector
|
|
316
|
+
}
|
|
317
|
+
Variant::Dark | Variant::HostContextDark => {
|
|
318
|
+
// The dark cascade rewrites `&` to a `:host([data-theme])`/`:root.dark`
|
|
319
|
+
// gate — host/root scope that a `$global` `@apply` cannot express.
|
|
320
|
+
acc.dark_cascade = true;
|
|
321
|
+
acc.needs_scope = true;
|
|
322
|
+
selector
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Resolve a variant list against `base` (`&` for `@apply`) into the structural
|
|
328
|
+
/// selector + wrapping decisions shared by `@apply` expansion.
|
|
329
|
+
pub fn resolve_variants(
|
|
330
|
+
variants: &[Variant],
|
|
331
|
+
base: &str,
|
|
332
|
+
theme: &crate::theme::ThemeRegistry,
|
|
333
|
+
) -> ResolvedVariants {
|
|
334
|
+
let mut acc = ResolvedVariants {
|
|
335
|
+
selector: base.to_string(),
|
|
336
|
+
at_rule: None,
|
|
337
|
+
dark_cascade: false,
|
|
338
|
+
needs_scope: false,
|
|
339
|
+
};
|
|
340
|
+
let mut selector = base.to_string();
|
|
341
|
+
for v in variants {
|
|
342
|
+
selector = apply_variant_to_selector(v, selector, theme, &mut acc);
|
|
343
|
+
}
|
|
344
|
+
acc.selector = selector;
|
|
345
|
+
acc
|
|
346
|
+
}
|
|
347
|
+
|
|
201
348
|
/// Parse the payload after `aria-`/`data-` into an [`AttrMatch`].
|
|
202
349
|
///
|
|
203
350
|
/// Two shapes:
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
//! `@apply` expansion edge matrix (Task 1.4 — R-APPLY-PARSE, R-APPLY-TESTS).
|
|
2
|
+
//!
|
|
3
|
+
//! Drives `compile_sfc_scoped` with authored `@style` blocks containing `@apply`
|
|
4
|
+
//! directives and snapshots the folded output. The matrix (per R-APPLY-TESTS):
|
|
5
|
+
//! base; single variant → nested; multi-token `@apply a b c`; multiple `@apply`
|
|
6
|
+
//! per rule; `@apply` in a nested rule; arbitrary-value `bg-[#fff]`; unknown-
|
|
7
|
+
//! utility error; `$global` variant rejection.
|
|
8
|
+
|
|
9
|
+
use aihu_css_core::{compile_sfc_scoped, parse_ast, CompileError, SfcStyleScope};
|
|
10
|
+
|
|
11
|
+
/// Build an SFC AST with the given authored `@style` content + scope. No
|
|
12
|
+
/// template classes so the snapshot is JUST theme tokens + the folded `@style`,
|
|
13
|
+
/// isolating the `@apply` expansion.
|
|
14
|
+
fn sfc_style(content: &str, scope: &str) -> aihu_css_core::SfcAst {
|
|
15
|
+
let json = format!(
|
|
16
|
+
r#"{{"tag":"X","astVersion":1,
|
|
17
|
+
"style":{{"content":{content},"scope":"{scope}"}},
|
|
18
|
+
"meta":{{"name":"X"}},"template":null}}"#,
|
|
19
|
+
content = serde_json::to_string(content).unwrap()
|
|
20
|
+
);
|
|
21
|
+
parse_ast(&json).unwrap()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn scoped(content: &str) -> String {
|
|
25
|
+
compile_sfc_scoped(&sfc_style(content, "scoped")).unwrap()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Compile via `expand_apply` directly (parser → expand → css) so the snapshot
|
|
29
|
+
/// is just the expanded `@style`, with no theme-token preamble.
|
|
30
|
+
fn expand(content: &str, scope: SfcStyleScope) -> Result<String, CompileError> {
|
|
31
|
+
aihu_css_core::expand_apply(content, scope, &aihu_css_core::ThemeRegistry::with_aihu_defaults())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── base utility inline ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
#[test]
|
|
37
|
+
fn base_utility_inlines_declarations() {
|
|
38
|
+
let css = expand(".btn { @apply inline-flex items-center; }", SfcStyleScope::Scoped).unwrap();
|
|
39
|
+
insta::assert_snapshot!(css);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── single variant → nested rule ─────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
#[test]
|
|
45
|
+
fn single_variant_lifts_to_nested_rule() {
|
|
46
|
+
// hover: → &:hover { … } on the recipe's OWN selector (NOT a
|
|
47
|
+
// `.hover\:bg-accent:hover` class rule).
|
|
48
|
+
let css = expand(".btn { @apply hover:bg-accent; }", SfcStyleScope::Scoped).unwrap();
|
|
49
|
+
assert!(css.contains("&:hover"), "variant lifts to nested &:hover: {css}");
|
|
50
|
+
assert!(
|
|
51
|
+
!css.contains(r"\:"),
|
|
52
|
+
"must NOT emit an escaped class selector: {css}"
|
|
53
|
+
);
|
|
54
|
+
insta::assert_snapshot!(css);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── multi-token @apply a b c ─────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn multi_token_apply() {
|
|
61
|
+
let css = expand(
|
|
62
|
+
".btn { @apply inline-flex items-center justify-center rounded-md; }",
|
|
63
|
+
SfcStyleScope::Scoped,
|
|
64
|
+
)
|
|
65
|
+
.unwrap();
|
|
66
|
+
insta::assert_snapshot!(css);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── multiple @apply per rule ─────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn multiple_apply_directives_per_rule() {
|
|
73
|
+
let css = expand(
|
|
74
|
+
".btn {\n @apply inline-flex;\n @apply rounded-md;\n @apply hover:bg-accent;\n}",
|
|
75
|
+
SfcStyleScope::Scoped,
|
|
76
|
+
)
|
|
77
|
+
.unwrap();
|
|
78
|
+
insta::assert_snapshot!(css);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── @apply in a nested rule ──────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
#[test]
|
|
84
|
+
fn apply_inside_nested_rule() {
|
|
85
|
+
let css = expand(
|
|
86
|
+
".card {\n display: grid;\n & .title { @apply font-medium; }\n}",
|
|
87
|
+
SfcStyleScope::Scoped,
|
|
88
|
+
)
|
|
89
|
+
.unwrap();
|
|
90
|
+
assert!(css.contains("font-weight: 500"), "nested @apply inlined: {css}");
|
|
91
|
+
insta::assert_snapshot!(css);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── arbitrary-value utility ──────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
#[test]
|
|
97
|
+
fn arbitrary_value_utility_in_apply() {
|
|
98
|
+
let css = expand(".swatch { @apply bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
|
|
99
|
+
assert!(
|
|
100
|
+
css.contains("background-color: #fff"),
|
|
101
|
+
"arbitrary value inlined verbatim: {css}"
|
|
102
|
+
);
|
|
103
|
+
insta::assert_snapshot!(css);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn arbitrary_value_variant_in_apply() {
|
|
108
|
+
let css = expand(".swatch { @apply hover:bg-[#fff]; }", SfcStyleScope::Scoped).unwrap();
|
|
109
|
+
assert!(css.contains("&:hover"));
|
|
110
|
+
assert!(css.contains("background-color: #fff"));
|
|
111
|
+
insta::assert_snapshot!(css);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── responsive + data + group variants ───────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
#[test]
|
|
117
|
+
fn responsive_variant_wraps_media() {
|
|
118
|
+
let css = expand(".grid { @apply md:flex; }", SfcStyleScope::Scoped).unwrap();
|
|
119
|
+
assert!(css.contains("@media (min-width:"), "md: wraps @media: {css}");
|
|
120
|
+
insta::assert_snapshot!(css);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#[test]
|
|
124
|
+
fn data_attribute_variant() {
|
|
125
|
+
let css = expand(
|
|
126
|
+
r#".btn { @apply data-[state=open]:bg-accent; }"#,
|
|
127
|
+
SfcStyleScope::Scoped,
|
|
128
|
+
)
|
|
129
|
+
.unwrap();
|
|
130
|
+
assert!(css.contains(r#"&[data-state="open"]"#), "{css}");
|
|
131
|
+
insta::assert_snapshot!(css);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn dark_variant_cascade_in_apply() {
|
|
136
|
+
let css = expand(".panel { @apply dark:bg-surface; }", SfcStyleScope::Scoped).unwrap();
|
|
137
|
+
assert!(
|
|
138
|
+
!css.contains(":host-context("),
|
|
139
|
+
"Firefox-safe: no :host-context(): {css}"
|
|
140
|
+
);
|
|
141
|
+
assert!(css.contains(r#":host([data-theme="dark"])"#), "{css}");
|
|
142
|
+
assert!(css.contains(":root.dark"), "{css}");
|
|
143
|
+
insta::assert_snapshot!(css);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── unknown utility error ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
#[test]
|
|
149
|
+
fn unknown_utility_is_compile_error() {
|
|
150
|
+
let err = expand(".btn { @apply totally-not-a-utility; }", SfcStyleScope::Scoped)
|
|
151
|
+
.expect_err("unknown utility must hard-error");
|
|
152
|
+
match err {
|
|
153
|
+
CompileError::UnknownApplyUtility { ref token } => {
|
|
154
|
+
assert_eq!(token, "totally-not-a-utility")
|
|
155
|
+
}
|
|
156
|
+
other => panic!("expected UnknownApplyUtility, got {other:?}"),
|
|
157
|
+
}
|
|
158
|
+
assert!(err.to_string().contains("unknown utility in @apply"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn unknown_utility_propagates_through_compile_sfc_scoped() {
|
|
163
|
+
let err = compile_sfc_scoped(&sfc_style(".btn { @apply nope-nope; }", "scoped"))
|
|
164
|
+
.expect_err("must propagate");
|
|
165
|
+
assert!(matches!(err, CompileError::UnknownApplyUtility { .. }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── $global variant rejection ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn global_apply_rejects_scope_implying_variant() {
|
|
172
|
+
let err = expand("body { @apply hover:bg-accent; }", SfcStyleScope::Global)
|
|
173
|
+
.expect_err("$global may not use a scope-implying variant");
|
|
174
|
+
match err {
|
|
175
|
+
CompileError::GlobalApplyVariant { ref token } => assert_eq!(token, "hover:bg-accent"),
|
|
176
|
+
other => panic!("expected GlobalApplyVariant, got {other:?}"),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn global_apply_allows_base_utility() {
|
|
182
|
+
// Base utilities are fine in $global — they inline as flat declarations.
|
|
183
|
+
let css = expand("body { @apply inline-flex; }", SfcStyleScope::Global).unwrap();
|
|
184
|
+
assert!(css.contains("display: inline-flex"), "{css}");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn global_apply_allows_media_variant() {
|
|
189
|
+
// A breakpoint variant implies no host/`&` scope (it only wraps in @media),
|
|
190
|
+
// so it is allowed in $global.
|
|
191
|
+
let css = expand("body { @apply md:flex; }", SfcStyleScope::Global).unwrap();
|
|
192
|
+
assert!(css.contains("@media (min-width:"), "{css}");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── end-to-end through compile_sfc_scoped (theme preamble included) ───────────
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn end_to_end_scoped_with_theme_preamble() {
|
|
199
|
+
let css = scoped(".btn { @apply inline-flex hover:bg-accent; }");
|
|
200
|
+
assert!(css.contains(":host {"), "theme tokens present: {css}");
|
|
201
|
+
assert!(css.contains("display: inline-flex"));
|
|
202
|
+
assert!(css.contains("&:hover"));
|
|
203
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//! R-NO-PREMIGRATION-BREAK (CRITICAL): every current `packages/ui/registry/*`
|
|
2
|
+
//! recipe's authored `@style` block must pass through `@apply` expansion with no
|
|
3
|
+
//! hard-error. If a recipe uses a utility token the table does not cover, this
|
|
4
|
+
//! test fails — and the fix is to cover the token in `tokens.rs` (or fix the
|
|
5
|
+
//! recipe) BEFORE enabling expansion, so turning `@apply` on does not break the
|
|
6
|
+
//! shipped recipes.
|
|
7
|
+
//!
|
|
8
|
+
//! The test reads the `.aihu` SFC files directly (relative to the crate, two
|
|
9
|
+
//! levels up to the repo root), extracts each `@style { … }` block body with a
|
|
10
|
+
//! brace-matched scan, strips `@theme` blocks (handled separately by the
|
|
11
|
+
//! emitter), and runs `expand_apply` on the remainder.
|
|
12
|
+
|
|
13
|
+
use std::path::PathBuf;
|
|
14
|
+
|
|
15
|
+
use aihu_css_core::{expand_apply, SfcStyleScope, ThemeRegistry};
|
|
16
|
+
|
|
17
|
+
/// The repo root, derived from this crate's manifest dir
|
|
18
|
+
/// (`<root>/packages/css-engine/crates/aihu-css-core`).
|
|
19
|
+
fn repo_root() -> PathBuf {
|
|
20
|
+
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
21
|
+
for _ in 0..4 {
|
|
22
|
+
p.pop();
|
|
23
|
+
}
|
|
24
|
+
p
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Extract the body inside `@style { … }` (brace-matched), or `None` if the file
|
|
28
|
+
/// has no `@style` block. `$global @style` is not used by the current recipes,
|
|
29
|
+
/// but we treat any `@style` block as scoped for this regression (the only
|
|
30
|
+
/// concern is whether tokens RESOLVE, which is scope-independent for base
|
|
31
|
+
/// utilities; variant tokens resolve identically — only the $global *rejection*
|
|
32
|
+
/// path differs, and none of these recipes use $global).
|
|
33
|
+
fn extract_style_body(src: &str) -> Option<String> {
|
|
34
|
+
// Find the `@style` BLOCK opener — `@style` followed only by whitespace then
|
|
35
|
+
// `{`. (A naive `find("@style")` would match the word inside a comment such
|
|
36
|
+
// as "see @style below"; the recipes contain exactly that.)
|
|
37
|
+
let mut search = 0usize;
|
|
38
|
+
let (at, after) = loop {
|
|
39
|
+
let rel = src[search..].find("@style")?;
|
|
40
|
+
let at = search + rel;
|
|
41
|
+
let after = &src[at + "@style".len()..];
|
|
42
|
+
let trimmed = after.trim_start();
|
|
43
|
+
if trimmed.starts_with('{') {
|
|
44
|
+
break (at, after);
|
|
45
|
+
}
|
|
46
|
+
search = at + "@style".len();
|
|
47
|
+
};
|
|
48
|
+
let _ = at;
|
|
49
|
+
let open = after.find('{')?;
|
|
50
|
+
let body_start = open + 1;
|
|
51
|
+
let bytes = after.as_bytes();
|
|
52
|
+
let mut depth = 1i32;
|
|
53
|
+
let mut i = body_start;
|
|
54
|
+
while i < bytes.len() {
|
|
55
|
+
match bytes[i] {
|
|
56
|
+
b'{' => depth += 1,
|
|
57
|
+
b'}' => {
|
|
58
|
+
depth -= 1;
|
|
59
|
+
if depth == 0 {
|
|
60
|
+
return Some(after[body_start..i].to_string());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
_ => {}
|
|
64
|
+
}
|
|
65
|
+
i += 1;
|
|
66
|
+
}
|
|
67
|
+
None
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Remove `@theme { … }` blocks (brace-matched) — the emitter strips these
|
|
71
|
+
/// before folding, so the `@apply` pass never sees them.
|
|
72
|
+
fn strip_theme(body: &str) -> String {
|
|
73
|
+
let mut out = String::new();
|
|
74
|
+
let mut rest = body;
|
|
75
|
+
while let Some(at) = rest.find("@theme") {
|
|
76
|
+
out.push_str(&rest[..at]);
|
|
77
|
+
let after = &rest[at + "@theme".len()..];
|
|
78
|
+
let Some(open) = after.find('{') else {
|
|
79
|
+
out.push_str(after);
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
let bytes = after.as_bytes();
|
|
83
|
+
let mut depth = 1i32;
|
|
84
|
+
let mut i = open + 1;
|
|
85
|
+
while i < bytes.len() {
|
|
86
|
+
match bytes[i] {
|
|
87
|
+
b'{' => depth += 1,
|
|
88
|
+
b'}' => {
|
|
89
|
+
depth -= 1;
|
|
90
|
+
if depth == 0 {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
_ => {}
|
|
95
|
+
}
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
rest = &after[i + 1..];
|
|
99
|
+
}
|
|
100
|
+
out.push_str(rest);
|
|
101
|
+
out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn every_registry_recipe_apply_resolves() {
|
|
106
|
+
let registry = repo_root().join("packages/ui/registry");
|
|
107
|
+
assert!(
|
|
108
|
+
registry.is_dir(),
|
|
109
|
+
"registry dir not found at {}",
|
|
110
|
+
registry.display()
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let theme = ThemeRegistry::with_aihu_defaults();
|
|
114
|
+
let mut checked = 0usize;
|
|
115
|
+
|
|
116
|
+
for entry in std::fs::read_dir(®istry).unwrap() {
|
|
117
|
+
let dir = entry.unwrap().path();
|
|
118
|
+
if !dir.is_dir() {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for file in std::fs::read_dir(&dir).unwrap() {
|
|
122
|
+
let path = file.unwrap().path();
|
|
123
|
+
if path.extension().and_then(|s| s.to_str()) != Some("aihu") {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
let src = std::fs::read_to_string(&path).unwrap();
|
|
127
|
+
let Some(body) = extract_style_body(&src) else {
|
|
128
|
+
continue;
|
|
129
|
+
};
|
|
130
|
+
if !body.contains("@apply") {
|
|
131
|
+
// No @apply → nothing to expand, but still assert it parses.
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
let stripped = strip_theme(&body);
|
|
135
|
+
let result = expand_apply(&stripped, SfcStyleScope::Scoped, &theme);
|
|
136
|
+
assert!(
|
|
137
|
+
result.is_ok(),
|
|
138
|
+
"recipe {} @apply expansion failed: {:?}",
|
|
139
|
+
path.display(),
|
|
140
|
+
result.err()
|
|
141
|
+
);
|
|
142
|
+
checked += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
assert!(
|
|
147
|
+
checked > 0,
|
|
148
|
+
"expected at least one recipe with @apply (button) to be checked"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//! Binary-level error propagation (R-RESULT): an induced emit error must exit
|
|
2
|
+
//! the `aihu-css-compile` binary non-zero AND print the message to stderr.
|
|
3
|
+
//! Cargo sets `CARGO_BIN_EXE_aihu-css-compile` for this integration test.
|
|
4
|
+
|
|
5
|
+
use std::io::Write;
|
|
6
|
+
use std::process::{Command, Stdio};
|
|
7
|
+
|
|
8
|
+
fn run_ast_mode(ast_json: &str) -> std::process::Output {
|
|
9
|
+
let bin = env!("CARGO_BIN_EXE_aihu-css-compile");
|
|
10
|
+
let mut child = Command::new(bin)
|
|
11
|
+
.arg("--ast-json")
|
|
12
|
+
.stdin(Stdio::piped())
|
|
13
|
+
.stdout(Stdio::piped())
|
|
14
|
+
.stderr(Stdio::piped())
|
|
15
|
+
.spawn()
|
|
16
|
+
.expect("spawn aihu-css-compile");
|
|
17
|
+
child
|
|
18
|
+
.stdin
|
|
19
|
+
.as_mut()
|
|
20
|
+
.expect("stdin")
|
|
21
|
+
.write_all(ast_json.as_bytes())
|
|
22
|
+
.expect("write stdin");
|
|
23
|
+
child.wait_with_output().expect("wait")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[test]
|
|
27
|
+
fn malformed_theme_exits_nonzero_with_stderr_message() {
|
|
28
|
+
// `@theme` with no `{` body → CompileError::MalformedTheme.
|
|
29
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
30
|
+
"style":{"content":"@theme --color-primary: red;","scope":"scoped"},
|
|
31
|
+
"meta":{"name":"X"},"template":null}"#;
|
|
32
|
+
let out = run_ast_mode(json);
|
|
33
|
+
assert!(
|
|
34
|
+
!out.status.success(),
|
|
35
|
+
"binary must exit non-zero on emit error; status={:?}",
|
|
36
|
+
out.status
|
|
37
|
+
);
|
|
38
|
+
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
39
|
+
assert!(
|
|
40
|
+
stderr.contains("aihu-css-compile:") && stderr.contains("malformed @theme"),
|
|
41
|
+
"stderr must carry the actionable message, got: {stderr:?}"
|
|
42
|
+
);
|
|
43
|
+
assert!(
|
|
44
|
+
out.stdout.is_empty(),
|
|
45
|
+
"no CSS should be written on error; stdout={:?}",
|
|
46
|
+
String::from_utf8_lossy(&out.stdout)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[test]
|
|
51
|
+
fn well_formed_input_exits_zero_and_emits_css() {
|
|
52
|
+
let json = r#"{"tag":"X","astVersion":1,
|
|
53
|
+
"style":{"content":"@theme { --color-primary: red; }","scope":"scoped"},
|
|
54
|
+
"meta":{"name":"X"},
|
|
55
|
+
"template":[{"kind":"element","tag":"div","attrs":[
|
|
56
|
+
{"kind":"static","name":"class","value":"bg-primary"}],"children":[]}]}"#;
|
|
57
|
+
let out = run_ast_mode(json);
|
|
58
|
+
assert!(out.status.success(), "well-formed input exits zero");
|
|
59
|
+
let css = String::from_utf8_lossy(&out.stdout);
|
|
60
|
+
assert!(css.contains("background-color: var(--color-primary)"), "{css}");
|
|
61
|
+
}
|
|
@@ -20,11 +20,11 @@ fn hit_returns_identical_output_and_skips_recompile() {
|
|
|
20
20
|
let mut cache = CssCache::new();
|
|
21
21
|
let ast = sfc("bg-primary p-4");
|
|
22
22
|
|
|
23
|
-
let first = cache.compile(&ast, 1);
|
|
23
|
+
let first = cache.compile(&ast, 1).unwrap();
|
|
24
24
|
assert_eq!(cache.recompiles(), 1, "first compile is a miss");
|
|
25
25
|
assert_eq!(cache.hits(), 0);
|
|
26
26
|
|
|
27
|
-
let second = cache.compile(&ast, 1);
|
|
27
|
+
let second = cache.compile(&ast, 1).unwrap();
|
|
28
28
|
assert_eq!(second, first, "cache hit returns byte-identical output");
|
|
29
29
|
assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
|
|
30
30
|
assert_eq!(cache.hits(), 1, "second compile is a hit");
|
|
@@ -33,8 +33,8 @@ fn hit_returns_identical_output_and_skips_recompile() {
|
|
|
33
33
|
#[test]
|
|
34
34
|
fn ast_change_invalidates_entry() {
|
|
35
35
|
let mut cache = CssCache::new();
|
|
36
|
-
cache.compile(&sfc("p-4"), 1);
|
|
37
|
-
cache.compile(&sfc("p-8"), 1); // different class → different hash
|
|
36
|
+
cache.compile(&sfc("p-4"), 1).unwrap();
|
|
37
|
+
cache.compile(&sfc("p-8"), 1).unwrap(); // different class → different hash
|
|
38
38
|
assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
|
|
39
39
|
assert_eq!(cache.hits(), 0);
|
|
40
40
|
}
|
|
@@ -43,8 +43,8 @@ fn ast_change_invalidates_entry() {
|
|
|
43
43
|
fn theme_version_change_invalidates_entry() {
|
|
44
44
|
let mut cache = CssCache::new();
|
|
45
45
|
let ast = sfc("bg-primary");
|
|
46
|
-
cache.compile(&ast, 1);
|
|
47
|
-
cache.compile(&ast, 2); // theme bumped → invalidate
|
|
46
|
+
cache.compile(&ast, 1).unwrap();
|
|
47
|
+
cache.compile(&ast, 2).unwrap(); // theme bumped → invalidate
|
|
48
48
|
assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -53,12 +53,12 @@ fn cache_hit_is_well_under_30ms() {
|
|
|
53
53
|
let mut cache = CssCache::new();
|
|
54
54
|
// Warm the cache.
|
|
55
55
|
let ast = sfc("bg-primary p-4 rounded-lg hover:bg-accent md:p-8 host:text-primary");
|
|
56
|
-
let _ = cache.compile(&ast, 1);
|
|
56
|
+
let _ = cache.compile(&ast, 1).unwrap();
|
|
57
57
|
|
|
58
58
|
// Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
|
|
59
59
|
let start = Instant::now();
|
|
60
60
|
for _ in 0..1000 {
|
|
61
|
-
let _ = cache.compile(&ast, 1);
|
|
61
|
+
let _ = cache.compile(&ast, 1).unwrap();
|
|
62
62
|
}
|
|
63
63
|
let elapsed = start.elapsed();
|
|
64
64
|
let per_hit = elapsed / 1000;
|