@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.
- package/README.md +27 -22
- 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 +195 -36
- package/crates/aihu-css-core/src/lib.rs +15 -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/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +1196 -29
- package/crates/aihu-css-core/src/variants.rs +251 -3
- 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 +284 -17
- 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 +80 -8
- 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 +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +24 -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 +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- 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 +526 -7
- 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/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +6 -6
|
@@ -32,14 +32,56 @@ 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: …)`.
|
|
40
45
|
Breakpoint(String),
|
|
41
46
|
/// `[&>div]:` arbitrary selector → native nesting (`& > div`).
|
|
42
47
|
ArbitrarySelector(String),
|
|
48
|
+
|
|
49
|
+
// ── relational (group / peer) ────────────────────────────────────────────
|
|
50
|
+
/// `group-hover:`, `group-focus:`, `group-focus-visible:`, `group-active:`,
|
|
51
|
+
/// `group-disabled:` → ancestor-state selector
|
|
52
|
+
/// (`.group:<state> <base>`). The `Option<String>` is the ancestor state
|
|
53
|
+
/// pseudo-class. A bare `group` (no state) is NOT a variant prefix — it is a
|
|
54
|
+
/// marker utility (see `tokens::fixed_utility`) applied directly to the
|
|
55
|
+
/// ancestor element; this arm only ever carries `Some(state)`.
|
|
56
|
+
Group(Option<String>),
|
|
57
|
+
/// `peer-hover:`, `peer-focus:`, `peer-focus-visible:`, `peer-checked:`,
|
|
58
|
+
/// `peer-disabled:` → previous-sibling-state selector
|
|
59
|
+
/// (`.peer:<state> ~ <base>`). As with [`Variant::Group`], the bare `peer`
|
|
60
|
+
/// marker is a utility, not a prefix; this arm only carries `Some(state)`.
|
|
61
|
+
Peer(Option<String>),
|
|
62
|
+
|
|
63
|
+
// ── attribute / container (round 2) ───────────────────────────────────────
|
|
64
|
+
/// `aria-checked:` → `&[aria-checked="true"]` (keyword form, implicit
|
|
65
|
+
/// `="true"`). `aria-[expanded=false]:` carries an explicit `name=value`
|
|
66
|
+
/// payload → `&[aria-expanded="false"]`.
|
|
67
|
+
Aria(AttrMatch),
|
|
68
|
+
/// `data-[state=open]:` → `&[data-state="open"]` (bracket payload
|
|
69
|
+
/// `name=value`). `data-active:` (keyword) → `&[data-active]` (presence).
|
|
70
|
+
Data(AttrMatch),
|
|
71
|
+
/// Container-query breakpoint variant: `@sm:`/`@md:`/`@lg:`/`@xl:`/`@2xl:`
|
|
72
|
+
/// → `@container (min-width: …)`. Wraps the rule like `Breakpoint`, but in
|
|
73
|
+
/// an `@container` at-rule instead of `@media`.
|
|
74
|
+
Container(String),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// An attribute-selector match payload for `aria-*` / `data-*` variants.
|
|
78
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
79
|
+
pub enum AttrMatch {
|
|
80
|
+
/// `aria-checked` → `[aria-checked="true"]` (aria) or `[data-active]`
|
|
81
|
+
/// presence (data). The bool records whether to imply `="true"`.
|
|
82
|
+
Name { name: String, imply_true: bool },
|
|
83
|
+
/// Explicit `name=value` → `[<full>-name="value"]`.
|
|
84
|
+
NameValue { name: String, value: String },
|
|
43
85
|
}
|
|
44
86
|
|
|
45
87
|
impl Variant {
|
|
@@ -108,17 +150,223 @@ fn parse_prefix(prefix: &str) -> Option<Variant> {
|
|
|
108
150
|
"host-context-dark" => Variant::HostContextDark,
|
|
109
151
|
"slotted" => Variant::Slotted,
|
|
110
152
|
"dark" => Variant::Dark,
|
|
111
|
-
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
|
|
112
|
-
| "
|
|
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()),
|
|
113
169
|
"sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
|
|
170
|
+
// Container-query breakpoints: `@sm`/`@md`/`@lg`/`@xl`/`@2xl`.
|
|
171
|
+
"@sm" | "@md" | "@lg" | "@xl" | "@2xl" => Variant::Container(prefix[1..].to_string()),
|
|
114
172
|
_ => {
|
|
115
173
|
if let Some(tag) = prefix.strip_prefix("slotted-") {
|
|
116
174
|
Variant::SlottedTag(tag.to_string())
|
|
117
175
|
} else if let Some(name) = prefix.strip_prefix("part-") {
|
|
118
176
|
Variant::Part(name.to_string())
|
|
177
|
+
} else if let Some(state) = group_state(prefix) {
|
|
178
|
+
Variant::Group(state)
|
|
179
|
+
} else if let Some(state) = peer_state(prefix) {
|
|
180
|
+
Variant::Peer(state)
|
|
181
|
+
} else if let Some(payload) = prefix.strip_prefix("aria-") {
|
|
182
|
+
Variant::Aria(parse_attr_match(payload, true))
|
|
183
|
+
} else if let Some(payload) = prefix.strip_prefix("data-") {
|
|
184
|
+
// data-* never implies `="true"`: bare `data-active` is a
|
|
185
|
+
// presence selector `[data-active]`.
|
|
186
|
+
Variant::Data(parse_attr_match(payload, false))
|
|
119
187
|
} else {
|
|
120
188
|
return None;
|
|
121
189
|
}
|
|
122
190
|
}
|
|
123
191
|
})
|
|
124
192
|
}
|
|
193
|
+
|
|
194
|
+
/// States a `group-*:` prefix may carry. Returns `Some(Some(state))` when
|
|
195
|
+
/// `prefix` is a recognized `group-<state>` form. (`group` alone is a marker
|
|
196
|
+
/// utility, not a variant — so it is NOT matched here.)
|
|
197
|
+
fn group_state(prefix: &str) -> Option<Option<String>> {
|
|
198
|
+
let state = prefix.strip_prefix("group-")?;
|
|
199
|
+
relational_state(state).map(Some)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// States a `peer-*:` prefix may carry. See [`group_state`].
|
|
203
|
+
fn peer_state(prefix: &str) -> Option<Option<String>> {
|
|
204
|
+
let state = prefix.strip_prefix("peer-")?;
|
|
205
|
+
relational_state(state).map(Some)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// The closed set of states `group-*:` / `peer-*:` accept. They map 1:1 to a
|
|
209
|
+
/// pseudo-class on the ancestor / previous-sibling marker element.
|
|
210
|
+
fn relational_state(state: &str) -> Option<String> {
|
|
211
|
+
matches!(
|
|
212
|
+
state,
|
|
213
|
+
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "checked" | "open"
|
|
214
|
+
)
|
|
215
|
+
.then(|| state.to_string())
|
|
216
|
+
}
|
|
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
|
+
|
|
348
|
+
/// Parse the payload after `aria-`/`data-` into an [`AttrMatch`].
|
|
349
|
+
///
|
|
350
|
+
/// Two shapes:
|
|
351
|
+
/// - `checked` (keyword) → `Name { name: "checked", imply_true }`.
|
|
352
|
+
/// - `[state=open]` (bracket, from `data-[state=open]`) → `NameValue`. The
|
|
353
|
+
/// bracket-aware splitter in [`split_variants`] keeps the `[...]` attached to
|
|
354
|
+
/// the prefix, so the payload arrives here as `[state=open]`.
|
|
355
|
+
fn parse_attr_match(payload: &str, imply_true: bool) -> AttrMatch {
|
|
356
|
+
// Strip an outer `[...]` if present (arbitrary form).
|
|
357
|
+
let inner = payload
|
|
358
|
+
.strip_prefix('[')
|
|
359
|
+
.and_then(|s| s.strip_suffix(']'))
|
|
360
|
+
.unwrap_or(payload);
|
|
361
|
+
if let Some((name, value)) = inner.split_once('=') {
|
|
362
|
+
AttrMatch::NameValue {
|
|
363
|
+
name: name.trim().to_string(),
|
|
364
|
+
value: value.trim().trim_matches('"').to_string(),
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
AttrMatch::Name {
|
|
368
|
+
name: inner.trim().to_string(),
|
|
369
|
+
imply_true,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -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
|
+
}
|