@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,8 +20,8 @@ use crate::ast::{SfcAst, SfcStyleScope};
20
20
  use crate::progressive::ProgressiveRegistry;
21
21
  use crate::scanner::{scan, ScanResult};
22
22
  use crate::theme::{extract_theme_blocks, ThemeRegistry};
23
- use crate::tokens::utility_to_css;
24
- use crate::variants::{split_variants, Variant};
23
+ use crate::tokens::{animation_keyframes, utility_to_css};
24
+ use crate::variants::{split_variants, AttrMatch, Variant};
25
25
 
26
26
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
27
  pub enum OutputMode {
@@ -29,11 +29,62 @@ pub enum OutputMode {
29
29
  Scoped,
30
30
  }
31
31
 
32
+ /// A recoverable error raised while emitting CSS from an SFC AST.
33
+ ///
34
+ /// This is the precursor error channel (R-RESULT): `emit_sfc_scoped` /
35
+ /// `compile_sfc_scoped` / the per-SFC cache return `Result<String, CompileError>`
36
+ /// so later passes (`@apply` unknown-utility, variant validation) can hard-error
37
+ /// instead of silently dropping. The `aihu-css-compile` binary prints the
38
+ /// `Display` message to stderr and exits non-zero; the TS bridge surfaces it as
39
+ /// a thrown `Error` carrying that message.
40
+ #[derive(Debug, Clone, PartialEq, Eq)]
41
+ pub enum CompileError {
42
+ /// An authored `@style` block opened `@theme` with no `{ … }` body.
43
+ MalformedTheme { detail: String },
44
+ /// An `@apply` directive referenced a utility token whose base utility is
45
+ /// not in the table (Task 1.4 — unknown utility hard-errors).
46
+ UnknownApplyUtility { token: String },
47
+ /// An `@apply` inside a `$global` `@style` block used a variant token that
48
+ /// implies `&`/host/relational scoping (Task 1.4 — base utilities allowed in
49
+ /// `$global`, scope-implying variants rejected).
50
+ GlobalApplyVariant { token: String },
51
+ /// A `@style` block failed to parse structurally (R-SHARED-PARSER).
52
+ StyleParse { detail: String },
53
+ }
54
+
55
+ impl std::fmt::Display for CompileError {
56
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57
+ match self {
58
+ CompileError::MalformedTheme { detail } => {
59
+ write!(f, "malformed @theme block in authored @style: {detail}")
60
+ }
61
+ CompileError::UnknownApplyUtility { token } => {
62
+ write!(f, "unknown utility in @apply: `{token}`")
63
+ }
64
+ CompileError::GlobalApplyVariant { token } => {
65
+ write!(
66
+ f,
67
+ "@apply in a $global @style block may not use the scope-implying \
68
+ variant `{token}` (only base utilities are allowed in $global)"
69
+ )
70
+ }
71
+ CompileError::StyleParse { detail } => {
72
+ write!(f, "failed to parse @style block: {detail}")
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ impl std::error::Error for CompileError {}
79
+
32
80
  /// CSS-escape a class name for use in a selector (`bg-[#fff]` → `bg-\[\#fff\]`).
33
81
  fn escape_class(class: &str) -> String {
34
82
  let mut out = String::with_capacity(class.len() + 4);
35
83
  for c in class.chars() {
36
- if matches!(c, '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',') {
84
+ if matches!(
85
+ c,
86
+ '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',' | '@' | '=' | '"'
87
+ ) {
37
88
  out.push('\\');
38
89
  }
39
90
  out.push(c);
@@ -71,7 +122,11 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
71
122
 
72
123
  // The base (innermost) selector and declaration body.
73
124
  let mut selector = class_sel;
74
- let mut media: Option<String> = None;
125
+ // Wrapping at-rule (e.g. `@media (min-width: …)` for breakpoints or
126
+ // `@container (min-width: …)` for container queries). Generalized from the
127
+ // old `media: Option<String>` slot so both `@media` and `@container` wrap
128
+ // the rule uniformly: `<at-rule> { <rule> }`.
129
+ let mut at_rule: Option<String> = None;
75
130
  let mut dark_cascade = false;
76
131
 
77
132
  for v in &variants {
@@ -81,13 +136,48 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
81
136
  Variant::SlottedTag(tag) => selector = format!("::slotted({tag}{selector})"),
82
137
  Variant::Part(name) => selector = format!("::part({name})"),
83
138
  Variant::Pseudo(pc) => selector = format!("{selector}:{pc}"),
139
+ Variant::PseudoElement(pe) => selector = format!("{selector}::{pe}"),
84
140
  Variant::ArbitrarySelector(sel) => {
85
141
  // `[&>div]:` → substitute `&` for the base selector.
86
142
  selector = sel.replace('&', &selector);
87
143
  }
144
+ Variant::Group(Some(state)) => {
145
+ // `group-hover:bg-x` → `.group:hover .group-hover\:bg-x`.
146
+ // Prepend a descendant-combinator ancestor selector: the rule
147
+ // applies to the element bearing this class when an ancestor
148
+ // marked `class="group"` is in `:<state>`. Within a shadow root
149
+ // both the marker and the styled element live in the same tree,
150
+ // so the class selectors match per spec §6.3 scoping.
151
+ selector = format!(".group:{state} {selector}");
152
+ }
153
+ Variant::Peer(Some(state)) => {
154
+ // `peer-checked:bg-x` → `.peer:checked ~ .peer-checked\:bg-x`.
155
+ // Prepend a subsequent-sibling-combinator selector: the rule
156
+ // applies when a PRIOR sibling marked `class="peer"` is in
157
+ // `:<state>`. CSS can only look backward to earlier siblings,
158
+ // so `peer` must appear before the styled element in source.
159
+ selector = format!(".peer:{state} ~ {selector}");
160
+ }
161
+ // Bare `group`/`peer` never reach here (they are marker utilities,
162
+ // not variant prefixes); a `None` state is unreachable but handled
163
+ // defensively as a no-op so the base selector is emitted unchanged.
164
+ Variant::Group(None) | Variant::Peer(None) => {}
165
+ // aria-*/data-* attribute variants compile to an attribute selector
166
+ // appended to the base: `aria-checked:` → `.cls[aria-checked="true"]`,
167
+ // `data-[state=open]:` → `.cls[data-state="open"]`. A keyword data-*
168
+ // (`data-active:`) emits a presence selector `[data-active]`.
169
+ Variant::Aria(m) => selector = format!("{selector}{}", attr_selector("aria", m)),
170
+ Variant::Data(m) => selector = format!("{selector}{}", attr_selector("data", m)),
88
171
  Variant::Breakpoint(bp) => {
89
172
  if let Some(min) = theme.breakpoint(bp) {
90
- media = Some(format!("(min-width: {min})"));
173
+ at_rule = Some(format!("@media (min-width: {min})"));
174
+ }
175
+ }
176
+ // Container queries wrap the rule in an `@container` at-rule keyed on
177
+ // the container breakpoint scale (mirrors `breakpoint()`).
178
+ Variant::Container(bp) => {
179
+ if let Some(min) = theme.container_breakpoint(bp) {
180
+ at_rule = Some(format!("@container (min-width: {min})"));
91
181
  }
92
182
  }
93
183
  Variant::Dark | Variant::HostContextDark => {
@@ -112,12 +202,41 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
112
202
  format!("{selector} {{ {body} }}\n")
113
203
  };
114
204
 
115
- Some(match media {
116
- Some(q) => format!("@media {q} {{\n{rule}}}\n"),
205
+ let rule = match at_rule {
206
+ Some(at) => format!("{at} {{\n{rule}}}\n"),
207
+ None => rule,
208
+ };
209
+
210
+ // Hoist the @keyframes an `animate-*` utility depends on as a top-level
211
+ // sibling rule (it cannot live nested inside the selector body). Re-emitting
212
+ // an identical block is idempotent in CSS, so per-occurrence emission is
213
+ // safe. `base` is the variant-stripped class (e.g. `animate-spin`).
214
+ Some(match animation_keyframes(&base) {
215
+ Some(kf) => format!("{rule}{kf}\n"),
117
216
  None => rule,
118
217
  })
119
218
  }
120
219
 
220
+ /// Build an attribute-selector fragment for an `aria-*`/`data-*` variant.
221
+ ///
222
+ /// `attr_selector("aria", Name{checked, true})` → `[aria-checked="true"]`;
223
+ /// `attr_selector("data", NameValue{state, open})` → `[data-state="open"]`;
224
+ /// `attr_selector("data", Name{active, false})` → `[data-active]` (presence).
225
+ pub(crate) fn attr_selector(family: &str, m: &AttrMatch) -> String {
226
+ match m {
227
+ AttrMatch::Name { name, imply_true } => {
228
+ if *imply_true {
229
+ format!("[{family}-{name}=\"true\"]")
230
+ } else {
231
+ format!("[{family}-{name}]")
232
+ }
233
+ }
234
+ AttrMatch::NameValue { name, value } => {
235
+ format!("[{family}-{name}=\"{value}\"]")
236
+ }
237
+ }
238
+ }
239
+
121
240
  /// Emit CSS for a scanned utility set in the given mode.
122
241
  pub fn emit(result: &ScanResult, theme: &ThemeRegistry, mode: OutputMode) -> String {
123
242
  emit_with_progressive(result, theme, &ProgressiveRegistry::with_builtins(), mode)
@@ -138,6 +257,10 @@ pub fn emit_with_progressive(
138
257
  // Flat back-compat: only plain utilities, no variant wrapping.
139
258
  if let Some(body) = utility_to_css(token) {
140
259
  out.push_str(&format!(".{token} {{ {body} }}\n"));
260
+ if let Some(kf) = animation_keyframes(token) {
261
+ out.push_str(kf);
262
+ out.push('\n');
263
+ }
141
264
  }
142
265
  }
143
266
  OutputMode::Scoped => {
@@ -152,7 +275,7 @@ pub fn emit_with_progressive(
152
275
 
153
276
  /// Compile a full SFC AST to scoped CSS: theme tokens (`:host`-level custom
154
277
  /// props) + scanned utility rules + the folded authored `@style` block.
155
- pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
278
+ pub fn emit_sfc_scoped(ast: &SfcAst) -> Result<String, CompileError> {
156
279
  let mut theme = ThemeRegistry::with_aihu_defaults();
157
280
 
158
281
  // Parse @theme directives from the authored style block first so utilities
@@ -166,52 +289,88 @@ pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
166
289
 
167
290
  let result = scan(ast);
168
291
  let prog = ProgressiveRegistry::with_builtins();
169
- let mut out = String::new();
170
292
 
171
- // 1. Theme tokens at :host so var(--color-*) resolves inside the shadow.
172
- out.push_str(&theme.emit_host_tokens());
293
+ // Build the rule body first preflight + scanned utilities + folded
294
+ // `@style` — so we can discover which palette `--color-*` tokens it
295
+ // references and register them before emitting the `:host` token block.
296
+ let mut body = String::new();
173
297
 
174
- // 2. Scanned utility rules (scoped) progressive prefixes routed via `prog`.
175
- out.push_str(&emit_with_progressive(&result, &theme, &prog, OutputMode::Scoped));
298
+ // Preflight border reset (Tailwind v4 parity). Browsers default
299
+ // `border-style: none`, so a bare `.border { border-width: 1px }` paints
300
+ // nothing. Emit a single one-time rule so every border utility renders a
301
+ // visible solid line. This is one rule per sheet (not per token), so the
302
+ // size impact is negligible; the matching utility wins by specificity.
303
+ body.push_str("*, ::before, ::after { border-style: solid; border-width: 0; }\n");
176
304
 
177
- // 3. Fold the authored @style block (minus @theme directives).
305
+ // Scanned utility rules (scoped) progressive prefixes routed via `prog`.
306
+ body.push_str(&emit_with_progressive(
307
+ &result,
308
+ &theme,
309
+ &prog,
310
+ OutputMode::Scoped,
311
+ ));
312
+
313
+ // Fold the authored @style block (minus @theme directives), expanding any
314
+ // `@apply` directives first (Task 1.4). Base utilities inline as
315
+ // declarations; variant tokens lift to nested `&…` rules on the recipe's
316
+ // own selector. Unknown utilities / illegal `$global` variants hard-error.
178
317
  if let Some(style) = &ast.style {
179
- let authored = strip_theme_blocks(&style.content);
180
- let authored = authored.trim();
181
- if !authored.is_empty() {
182
- match style.scope {
183
- // Scoped: it already lives in the shadow <style>; pass through.
184
- SfcStyleScope::Scoped => {
185
- out.push_str("/* authored @style (scoped) */\n");
186
- out.push_str(authored);
187
- out.push('\n');
188
- }
189
- // Global ($global): passed through unscoped (edge E6). The
190
- // compiler hoists this out of the shadow root.
191
- SfcStyleScope::Global => {
192
- out.push_str("/* authored @style ($global unscoped) */\n");
193
- out.push_str(authored);
194
- out.push('\n');
318
+ let stripped = strip_theme_blocks(&style.content)?;
319
+ if !stripped.trim().is_empty() {
320
+ let authored = crate::apply::expand_apply(&stripped, style.scope, &theme)?;
321
+ let authored = authored.trim();
322
+ if !authored.is_empty() {
323
+ match style.scope {
324
+ // Scoped: it already lives in the shadow <style>; pass through.
325
+ SfcStyleScope::Scoped => {
326
+ body.push_str("/* authored @style (scoped) */\n");
327
+ body.push_str(authored);
328
+ body.push('\n');
329
+ }
330
+ // Global ($global): passed through unscoped (edge E6). The
331
+ // compiler hoists this out of the shadow root.
332
+ SfcStyleScope::Global => {
333
+ body.push_str("/* authored @style ($global — unscoped) */\n");
334
+ body.push_str(authored);
335
+ body.push('\n');
336
+ }
195
337
  }
196
338
  }
197
339
  }
198
340
  }
199
341
 
200
- out
342
+ // Register the palette tokens the body references (Tailwind ships the full
343
+ // palette in its default theme) so `var(--color-amber-200)` resolves at
344
+ // `:host`. Only the referenced tokens are added — not all 286.
345
+ crate::tokens::register_used_palette(&body, &mut theme);
346
+
347
+ // Theme tokens at :host (now incl. used palette) so var(--color-*) resolves
348
+ // inside the shadow root, then the rule body.
349
+ let mut out = theme.emit_host_tokens();
350
+ out.push_str(&body);
351
+
352
+ Ok(out)
201
353
  }
202
354
 
203
355
  /// Remove `@theme { ... }` blocks from style content (they become host tokens,
204
356
  /// not raw CSS).
205
- fn strip_theme_blocks(style_content: &str) -> String {
357
+ ///
358
+ /// An `@theme` opener with no `{ … }` body is a malformed authored block and
359
+ /// now hard-errors via [`CompileError`] (R-RESULT) instead of silently keeping
360
+ /// the broken text verbatim — the first real error this `Result` channel
361
+ /// surfaces. Later passes (`@apply`, variant validation) add more variants.
362
+ fn strip_theme_blocks(style_content: &str) -> Result<String, CompileError> {
206
363
  let mut out = String::new();
207
364
  let mut rest = style_content;
208
365
  while let Some(at) = rest.find("@theme") {
209
366
  out.push_str(&rest[..at]);
210
367
  let after = &rest[at + "@theme".len()..];
211
368
  let Some(open) = after.find('{') else {
212
- // Malformed keep the rest verbatim and stop.
213
- out.push_str(&rest[at..]);
214
- return out;
369
+ // Malformed: `@theme` with no `{` body. Hard-error rather than
370
+ // emitting the broken text verbatim.
371
+ return Err(CompileError::MalformedTheme {
372
+ detail: "expected `{` after `@theme`".to_string(),
373
+ });
215
374
  };
216
375
  let body_start = open + 1;
217
376
  let mut depth = 1u32;
@@ -232,5 +391,5 @@ fn strip_theme_blocks(style_content: &str) -> String {
232
391
  rest = &after[end + 1..];
233
392
  }
234
393
  out.push_str(rest);
235
- out
394
+ Ok(out)
236
395
  }
@@ -5,21 +5,29 @@
5
5
  //! of utility classes (see tokens.rs); Plan 2 wires the AST scanner; Plan 3
6
6
  //! adds variants and progressive features.
7
7
 
8
+ pub mod apply;
8
9
  pub mod ast;
9
10
  pub mod cache;
10
11
  pub mod emit;
11
12
  pub mod features;
13
+ pub mod palette;
12
14
  pub mod progressive;
13
15
  pub mod scanner;
16
+ pub mod style_parser;
14
17
  pub mod theme;
15
18
  pub mod tokens;
16
19
  pub mod variants;
17
20
 
21
+ pub use apply::expand_apply;
18
22
  pub use ast::{parse_ast, AstError, SfcAst, SfcAttr, SfcNode, SfcStyleScope};
19
23
  pub use cache::{hash_ast, CssCache};
20
- pub use emit::{emit, emit_sfc_scoped, OutputMode};
24
+ pub use emit::{emit, emit_sfc_scoped, CompileError, OutputMode};
21
25
  pub use progressive::{ProgressiveFeature, ProgressiveRegistry};
22
26
  pub use scanner::{scan, scan_ast, ScanResult};
27
+ pub use style_parser::{
28
+ parse_style, ApplyDirective, AtRule, AtStatement, Declaration, StyleNode, StyleParseError,
29
+ StyleRule, StyleSheet,
30
+ };
23
31
  pub use theme::ThemeRegistry;
24
32
  pub use variants::{split_variants, Variant};
25
33
 
@@ -42,6 +50,11 @@ pub fn compile_classes(classes: &[String]) -> String {
42
50
  output.push_str(" { ");
43
51
  output.push_str(&body);
44
52
  output.push_str(" }\n");
53
+ // Hoist the matching @keyframes as a sibling rule (animate-* only).
54
+ if let Some(kf) = tokens::animation_keyframes(class) {
55
+ output.push_str(kf);
56
+ output.push('\n');
57
+ }
45
58
  }
46
59
  }
47
60
  output
@@ -62,6 +75,6 @@ pub fn compile_sfc(ast: &SfcAst) -> String {
62
75
  /// `:host`-level theme tokens, variant-resolved utility rules, and the folded
63
76
  /// authored `@style` block. This is the production entry consumed by the TS
64
77
  /// bridge / `aihu-css-compile --ast-json`.
65
- pub fn compile_sfc_scoped(ast: &SfcAst) -> String {
78
+ pub fn compile_sfc_scoped(ast: &SfcAst) -> Result<String, CompileError> {
66
79
  emit_sfc_scoped(ast)
67
80
  }