@aihu/css-engine 0.3.0 → 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 (49) hide show
  1. package/README.md +11 -11
  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 +110 -30
  6. package/crates/aihu-css-core/src/lib.rs +10 -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/tokens.rs +625 -29
  10. package/crates/aihu-css-core/src/variants.rs +154 -7
  11. package/crates/aihu-css-core/tests/apply.rs +203 -0
  12. package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
  13. package/crates/aihu-css-core/tests/binary_error.rs +61 -0
  14. package/crates/aihu-css-core/tests/cache.rs +8 -8
  15. package/crates/aihu-css-core/tests/emit.rs +95 -36
  16. package/crates/aihu-css-core/tests/parity.rs +274 -0
  17. package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
  18. package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
  19. package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
  20. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
  21. package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
  22. package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
  23. package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
  24. package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
  25. package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
  26. package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
  27. package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
  28. package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
  30. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
  31. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
  32. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
  37. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
  38. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
  39. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
  40. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
  41. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
  42. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
  43. package/crates/aihu-css-core/tests/style_parser.rs +257 -0
  44. package/crates/aihu-css-core/tests/tokens.rs +52 -0
  45. package/dist/index.d.ts +0 -9
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +26 -18
  48. package/dist/index.js.map +1 -1
  49. package/package.json +6 -6
package/README.md CHANGED
@@ -198,7 +198,7 @@ npm install @aihu/css-engine
198
198
  bun add @aihu/css-engine
199
199
  ```
200
200
 
201
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
201
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
202
202
 
203
203
  <!-- END_AUTOGEN: install -->
204
204
 
@@ -209,12 +209,12 @@ bun add @aihu/css-engine
209
209
 
210
210
  | | |
211
211
  |---|---|
212
- | **Version** | `0.3.0` |
212
+ | **Version** | `0.4.0` |
213
213
  | **Tier** | D — Compiler — CSS engine (Tailwind v4 hard fork, WC-native scoped output) |
214
214
  | **Published files** | 5 entries |
215
215
  | **License** | MIT |
216
216
 
217
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
217
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
218
218
 
219
219
  <!-- END_AUTOGEN: stats -->
220
220
 
@@ -233,7 +233,7 @@ bun add @aihu/css-engine
233
233
  | `./runtime/cn` | `./dist/runtime/cn.js` | `—` |
234
234
  | `./runtime/progressive` | `./dist/runtime/progressive.js` | `—` |
235
235
 
236
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
236
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
237
237
 
238
238
  <!-- END_AUTOGEN: exports -->
239
239
 
@@ -248,12 +248,12 @@ bun add @aihu/css-engine
248
248
 
249
249
  **Optional dependencies (platform-specific):**
250
250
 
251
- - `@aihu/css-engine-darwin-arm64` — `0.1.2`
252
- - `@aihu/css-engine-darwin-x64` — `0.1.2`
253
- - `@aihu/css-engine-linux-x64-gnu` — `0.1.2`
254
- - `@aihu/css-engine-win32-x64-msvc` — `0.1.2`
251
+ - `@aihu/css-engine-darwin-arm64` — `0.1.3`
252
+ - `@aihu/css-engine-darwin-x64` — `0.1.3`
253
+ - `@aihu/css-engine-linux-x64-gnu` — `0.1.3`
254
+ - `@aihu/css-engine-win32-x64-msvc` — `0.1.3`
255
255
 
256
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
256
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
257
257
 
258
258
  <!-- END_AUTOGEN: deps -->
259
259
 
@@ -266,7 +266,7 @@ bun add @aihu/css-engine
266
266
  - [@aihu/compiler](../compiler)
267
267
  - [Aihu framework root](../../README.md)
268
268
 
269
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
269
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
270
270
 
271
271
  <!-- END_AUTOGEN: see-also -->
272
272
 
@@ -277,6 +277,6 @@ bun add @aihu/css-engine
277
277
 
278
278
  MIT — see [LICENSE](../../LICENSE).
279
279
 
280
- <sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
280
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
281
281
 
282
282
  <!-- END_AUTOGEN: license -->
@@ -0,0 +1,314 @@
1
+ //! `apply.rs` — `@apply` expansion inside `@style` (Task 1.4, R-APPLY-PARSE).
2
+ //!
3
+ //! Consumes the structured rule tree from [`crate::style_parser`] and replaces
4
+ //! every rule's `@apply <tokens>` directives in place:
5
+ //!
6
+ //! - **Base** tokens (no variant prefix) inline the *same declarations* their
7
+ //! utility class produces ([`utility_to_css`]) directly into the current
8
+ //! rule's declaration list, in source order, before the rule's authored
9
+ //! declarations? No — appended after, so authored declarations can override
10
+ //! (last-wins, matching CSS). Actually `@apply` directives are emitted in
11
+ //! source order relative to the rule body; we splice the inlined declarations
12
+ //! at the point the directive appears. See [`expand_rule`].
13
+ //! - **Variant** tokens (`hover:`, `data-[state=open]:`, `group:`, `md:`,
14
+ //! `dark:`, arbitrary `[&>x]:`, host/slotted/part, …) resolve STRUCTURALLY via
15
+ //! [`crate::variants::resolve_variants`] against `&` (the recipe's own
16
+ //! selector) to a *nested* rule on the current rule — `@apply hover:bg-accent`
17
+ //! inside `.btn {}` becomes `.btn { & { … } &:hover { background: … } }`. We
18
+ //! do NOT call `emit_token` + strip the class selector (that yields the wrong
19
+ //! `.hover\:bg-accent:hover` output — Codex).
20
+ //!
21
+ //! Unknown base utility → [`CompileError::UnknownApplyUtility`]. In a `$global`
22
+ //! block, a variant token that implies `&`/host/relational scoping →
23
+ //! [`CompileError::GlobalApplyVariant`] (base utilities still allowed).
24
+
25
+ use crate::ast::SfcStyleScope;
26
+ use crate::emit::CompileError;
27
+ use crate::style_parser::{
28
+ parse_style, ApplyDirective, AtRule, Declaration, StyleNode, StyleRule, StyleSheet,
29
+ };
30
+ use crate::theme::ThemeRegistry;
31
+ use crate::tokens::utility_to_css;
32
+ use crate::variants::{resolve_variants, split_variants};
33
+
34
+ /// Parse an authored `@style` body, expand every `@apply`, and render back to
35
+ /// CSS. This is the single entry the emitter calls in place of folding the raw
36
+ /// `@style` text verbatim.
37
+ pub fn expand_apply(
38
+ style_content: &str,
39
+ scope: SfcStyleScope,
40
+ theme: &ThemeRegistry,
41
+ ) -> Result<String, CompileError> {
42
+ let mut sheet = parse_style(style_content).map_err(|e| CompileError::StyleParse {
43
+ detail: e.to_string(),
44
+ })?;
45
+ expand_sheet(&mut sheet, scope, theme)?;
46
+ Ok(sheet.to_css())
47
+ }
48
+
49
+ /// Expand `@apply` across every node in a parsed sheet, in place.
50
+ fn expand_sheet(
51
+ sheet: &mut StyleSheet,
52
+ scope: SfcStyleScope,
53
+ theme: &ThemeRegistry,
54
+ ) -> Result<(), CompileError> {
55
+ for node in &mut sheet.nodes {
56
+ expand_node(node, scope, theme)?;
57
+ }
58
+ Ok(())
59
+ }
60
+
61
+ fn expand_node(
62
+ node: &mut StyleNode,
63
+ scope: SfcStyleScope,
64
+ theme: &ThemeRegistry,
65
+ ) -> Result<(), CompileError> {
66
+ match node {
67
+ StyleNode::Rule(rule) => expand_rule(rule, scope, theme),
68
+ StyleNode::AtRule(at) => expand_at_rule(at, scope, theme),
69
+ StyleNode::AtStatement(_) => Ok(()),
70
+ }
71
+ }
72
+
73
+ fn expand_at_rule(
74
+ at: &mut AtRule,
75
+ scope: SfcStyleScope,
76
+ theme: &ThemeRegistry,
77
+ ) -> Result<(), CompileError> {
78
+ for node in &mut at.body {
79
+ expand_node(node, scope, theme)?;
80
+ }
81
+ Ok(())
82
+ }
83
+
84
+ /// Expand a single rule: inline base-utility declarations, lift variant tokens
85
+ /// into nested rules, recurse into already-nested rules, then drop the consumed
86
+ /// `@apply` directives.
87
+ fn expand_rule(
88
+ rule: &mut StyleRule,
89
+ scope: SfcStyleScope,
90
+ theme: &ThemeRegistry,
91
+ ) -> Result<(), CompileError> {
92
+ // Take the directives out; we re-classify their tokens into inline
93
+ // declarations (base) and nested rules (variant).
94
+ let applies = std::mem::take(&mut rule.applies);
95
+ let mut inlined: Vec<Declaration> = Vec::new();
96
+ let mut nested_from_apply: Vec<StyleNode> = Vec::new();
97
+
98
+ for ApplyDirective { tokens } in &applies {
99
+ for token in tokens {
100
+ let (variants, base) = split_variants(token);
101
+ let body = utility_to_css(&base).ok_or_else(|| CompileError::UnknownApplyUtility {
102
+ token: token.clone(),
103
+ })?;
104
+
105
+ if variants.is_empty() {
106
+ // Base utility: inline its declarations into the current rule.
107
+ inlined.extend(parse_declarations(&body));
108
+ continue;
109
+ }
110
+
111
+ // Variant token → structural nested rule on `&`.
112
+ let resolved = resolve_variants(&variants, "&", theme);
113
+
114
+ // `$global` blocks have no `&`/host scope: reject scope-implying
115
+ // variants; a bare breakpoint/container/dark variant has no
116
+ // host/relational implication and is allowed.
117
+ if matches!(scope, SfcStyleScope::Global) && resolved.needs_scope {
118
+ return Err(CompileError::GlobalApplyVariant {
119
+ token: token.clone(),
120
+ });
121
+ }
122
+
123
+ let decls = parse_declarations(&body);
124
+ let nested_rule = StyleRule {
125
+ selector: resolved.selector,
126
+ declarations: decls,
127
+ applies: Vec::new(),
128
+ nested: Vec::new(),
129
+ };
130
+
131
+ // Wrap in dark-cascade and/or at-rule layers as needed. The dark
132
+ // cascade rewrites the selector to the Firefox-safe gate; the
133
+ // at-rule (media/container) wraps the whole thing.
134
+ let mut node = if resolved.dark_cascade {
135
+ dark_cascade_node(nested_rule)
136
+ } else {
137
+ StyleNode::Rule(nested_rule)
138
+ };
139
+ if let Some(at) = resolved.at_rule {
140
+ let (name, prelude) = split_at_prelude(&at);
141
+ node = StyleNode::AtRule(AtRule {
142
+ name,
143
+ prelude,
144
+ body: vec![node],
145
+ });
146
+ }
147
+ nested_from_apply.push(node);
148
+ }
149
+ }
150
+
151
+ // Inlined base declarations come first (so the rule's own authored
152
+ // declarations, appended after, win on conflict — CSS last-declaration
153
+ // wins). Prepend to preserve that ordering.
154
+ if !inlined.is_empty() {
155
+ let mut decls = inlined;
156
+ decls.append(&mut rule.declarations);
157
+ rule.declarations = decls;
158
+ }
159
+
160
+ // Variant-derived nested rules go before any authored nested nodes so the
161
+ // expanded output reads `&:hover {…}` right after the base declarations.
162
+ if !nested_from_apply.is_empty() {
163
+ nested_from_apply.append(&mut rule.nested);
164
+ rule.nested = nested_from_apply;
165
+ }
166
+
167
+ // Recurse into nested rules (authored or expanded — expanded ones carry no
168
+ // further `@apply`, so this is a no-op for them but keeps the walk uniform).
169
+ for node in &mut rule.nested {
170
+ expand_node(node, scope, theme)?;
171
+ }
172
+
173
+ Ok(())
174
+ }
175
+
176
+ /// Build the Firefox-safe dark-cascade node for a resolved variant rule. Mirrors
177
+ /// the gate the emitter uses (`emit_token`): the rule applies only under
178
+ /// `:host([data-theme="dark"])` or `:root.dark`. The nested `&` in the resolved
179
+ /// selector is expanded against each gate prefix.
180
+ fn dark_cascade_node(rule: StyleRule) -> StyleNode {
181
+ // `rule.selector` is the resolved selector starting from `&` (e.g. `&` for a
182
+ // bare `dark:`, or `&:hover` for `dark:hover:`). Compose the two gated
183
+ // selectors by substituting the host/root prefix for the leading `&`.
184
+ let sel = rule.selector;
185
+ let host = sel.replacen('&', ":host([data-theme=\"dark\"]) &", 1);
186
+ let root = sel.replacen('&', ":root.dark &", 1);
187
+ StyleNode::Rule(StyleRule {
188
+ selector: format!("{host}, {root}"),
189
+ declarations: rule.declarations,
190
+ applies: Vec::new(),
191
+ nested: Vec::new(),
192
+ })
193
+ }
194
+
195
+ /// Split a wrapping at-rule string (`@media (min-width: 600px)`) into its name
196
+ /// (`@media`) and prelude (`(min-width: 600px)`).
197
+ fn split_at_prelude(at: &str) -> (String, String) {
198
+ let at = at.trim();
199
+ match at.find(char::is_whitespace) {
200
+ Some(i) => (at[..i].to_string(), at[i..].trim().to_string()),
201
+ None => (at.to_string(), String::new()),
202
+ }
203
+ }
204
+
205
+ /// Split a utility's emitted CSS body (`"display: flex; align-items: center;"`)
206
+ /// into individual [`Declaration`]s. The bodies the token table produces are
207
+ /// simple `prop: value;` lists, but values can contain `:` (e.g.
208
+ /// `cubic-bezier(...)`) and `;` inside parens — so split on top-level `;`/`:`
209
+ /// honouring parens/brackets/strings (reusing the same discipline as the parser).
210
+ fn parse_declarations(body: &str) -> Vec<Declaration> {
211
+ let mut out = Vec::new();
212
+ for chunk in split_top_level_semicolons(body) {
213
+ let chunk = chunk.trim();
214
+ if chunk.is_empty() {
215
+ continue;
216
+ }
217
+ if let Some((prop, value)) = split_first_colon(chunk) {
218
+ out.push(Declaration {
219
+ prop: prop.trim().to_string(),
220
+ value: value.trim().to_string(),
221
+ });
222
+ }
223
+ // A chunk with no top-level `:` (e.g. a nested `& > * + * { … }` body
224
+ // from `divide-*`/`space-*`) is not a flat declaration; those utilities
225
+ // are not used via `@apply` in the current recipes, and emitting them as
226
+ // a raw declaration would be wrong. We drop such non-declaration chunks
227
+ // here rather than mis-emit; if a recipe needs them, they should be
228
+ // authored directly (R-NO-PREMIGRATION-BREAK regression guards this).
229
+ }
230
+ out
231
+ }
232
+
233
+ /// Split on top-level `;` (not inside `(...)`, `[...]`, or strings).
234
+ fn split_top_level_semicolons(body: &str) -> Vec<&str> {
235
+ let bytes = body.as_bytes();
236
+ let mut out = Vec::new();
237
+ let mut start = 0usize;
238
+ let mut paren = 0u32;
239
+ let mut bracket = 0u32;
240
+ let mut i = 0usize;
241
+ while i < bytes.len() {
242
+ match bytes[i] {
243
+ b'"' | b'\'' => {
244
+ let q = bytes[i];
245
+ i += 1;
246
+ while i < bytes.len() {
247
+ if bytes[i] == b'\\' {
248
+ i += 2;
249
+ continue;
250
+ }
251
+ if bytes[i] == q {
252
+ i += 1;
253
+ break;
254
+ }
255
+ i += 1;
256
+ }
257
+ continue;
258
+ }
259
+ b'(' => paren += 1,
260
+ b')' => paren = paren.saturating_sub(1),
261
+ b'[' => bracket += 1,
262
+ b']' => bracket = bracket.saturating_sub(1),
263
+ b';' if paren == 0 && bracket == 0 => {
264
+ out.push(&body[start..i]);
265
+ start = i + 1;
266
+ }
267
+ _ => {}
268
+ }
269
+ i += 1;
270
+ }
271
+ if start < body.len() {
272
+ out.push(&body[start..]);
273
+ }
274
+ out
275
+ }
276
+
277
+ /// Split a declaration chunk on its first top-level `:` (none inside
278
+ /// `(...)`/`[...]`/strings).
279
+ fn split_first_colon(chunk: &str) -> Option<(&str, &str)> {
280
+ let bytes = chunk.as_bytes();
281
+ let mut paren = 0u32;
282
+ let mut bracket = 0u32;
283
+ let mut i = 0usize;
284
+ while i < bytes.len() {
285
+ match bytes[i] {
286
+ b'"' | b'\'' => {
287
+ let q = bytes[i];
288
+ i += 1;
289
+ while i < bytes.len() {
290
+ if bytes[i] == b'\\' {
291
+ i += 2;
292
+ continue;
293
+ }
294
+ if bytes[i] == q {
295
+ i += 1;
296
+ break;
297
+ }
298
+ i += 1;
299
+ }
300
+ continue;
301
+ }
302
+ b'(' => paren += 1,
303
+ b')' => paren = paren.saturating_sub(1),
304
+ b'[' => bracket += 1,
305
+ b']' => bracket = bracket.saturating_sub(1),
306
+ b':' if paren == 0 && bracket == 0 => {
307
+ return Some((&chunk[..i], &chunk[i + 1..]));
308
+ }
309
+ _ => {}
310
+ }
311
+ i += 1;
312
+ }
313
+ None
314
+ }
@@ -48,13 +48,14 @@ fn main() {
48
48
  }
49
49
 
50
50
  let css = if ast_mode {
51
- match aihu_css_core::parse_ast(&buf) {
52
- Ok(ast) => aihu_css_core::compile_sfc_scoped(&ast),
53
- Err(e) => {
54
- eprintln!("aihu-css-compile: {e}");
55
- std::process::exit(1);
56
- }
57
- }
51
+ let ast = aihu_css_core::parse_ast(&buf).unwrap_or_else(|e| {
52
+ eprintln!("aihu-css-compile: {e}");
53
+ std::process::exit(1);
54
+ });
55
+ aihu_css_core::compile_sfc_scoped(&ast).unwrap_or_else(|e| {
56
+ eprintln!("aihu-css-compile: {e}");
57
+ std::process::exit(1);
58
+ })
58
59
  } else {
59
60
  let classes: Vec<String> = buf
60
61
  .lines()
@@ -11,7 +11,7 @@ use std::collections::HashMap;
11
11
  use std::hash::{Hash, Hasher};
12
12
 
13
13
  use crate::ast::{SfcAst, SfcAttr, SfcNode, SfcStyleScope};
14
- use crate::emit::emit_sfc_scoped;
14
+ use crate::emit::{emit_sfc_scoped, CompileError};
15
15
 
16
16
  /// An in-process compilation cache. Construct one per dev session / build run.
17
17
  #[derive(Debug, Default)]
@@ -32,16 +32,19 @@ impl CssCache {
32
32
  /// Compile an SFC, returning a cached result on an unchanged-input hit.
33
33
  /// `theme_version` participates in the key so a theme change invalidates
34
34
  /// every entry.
35
- pub fn compile(&mut self, ast: &SfcAst, theme_version: u64) -> String {
35
+ ///
36
+ /// On a compile error (R-RESULT) the error propagates and nothing is
37
+ /// cached, so a later fixed input re-runs the full compile path.
38
+ pub fn compile(&mut self, ast: &SfcAst, theme_version: u64) -> Result<String, CompileError> {
36
39
  let key = hash_ast(ast, theme_version);
37
40
  if let Some(cached) = self.entries.get(&key) {
38
41
  self.hits += 1;
39
- return cached.clone();
42
+ return Ok(cached.clone());
40
43
  }
41
44
  self.recompiles += 1;
42
- let css = emit_sfc_scoped(ast);
45
+ let css = emit_sfc_scoped(ast)?;
43
46
  self.entries.insert(key, css.clone());
44
- css
47
+ Ok(css)
45
48
  }
46
49
 
47
50
  /// Total full recompiles since construction.
@@ -29,6 +29,54 @@ 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);
@@ -88,6 +136,7 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
88
136
  Variant::SlottedTag(tag) => selector = format!("::slotted({tag}{selector})"),
89
137
  Variant::Part(name) => selector = format!("::part({name})"),
90
138
  Variant::Pseudo(pc) => selector = format!("{selector}:{pc}"),
139
+ Variant::PseudoElement(pe) => selector = format!("{selector}::{pe}"),
91
140
  Variant::ArbitrarySelector(sel) => {
92
141
  // `[&>div]:` → substitute `&` for the base selector.
93
142
  selector = sel.replace('&', &selector);
@@ -173,7 +222,7 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
173
222
  /// `attr_selector("aria", Name{checked, true})` → `[aria-checked="true"]`;
174
223
  /// `attr_selector("data", NameValue{state, open})` → `[data-state="open"]`;
175
224
  /// `attr_selector("data", Name{active, false})` → `[data-active]` (presence).
176
- fn attr_selector(family: &str, m: &AttrMatch) -> String {
225
+ pub(crate) fn attr_selector(family: &str, m: &AttrMatch) -> String {
177
226
  match m {
178
227
  AttrMatch::Name { name, imply_true } => {
179
228
  if *imply_true {
@@ -226,7 +275,7 @@ pub fn emit_with_progressive(
226
275
 
227
276
  /// Compile a full SFC AST to scoped CSS: theme tokens (`:host`-level custom
228
277
  /// props) + scanned utility rules + the folded authored `@style` block.
229
- pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
278
+ pub fn emit_sfc_scoped(ast: &SfcAst) -> Result<String, CompileError> {
230
279
  let mut theme = ThemeRegistry::with_aihu_defaults();
231
280
 
232
281
  // Parse @theme directives from the authored style block first so utilities
@@ -240,57 +289,88 @@ pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
240
289
 
241
290
  let result = scan(ast);
242
291
  let prog = ProgressiveRegistry::with_builtins();
243
- let mut out = String::new();
244
292
 
245
- // 1. Theme tokens at :host so var(--color-*) resolves inside the shadow.
246
- 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();
297
+
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");
247
304
 
248
- // 2. Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
249
- out.push_str(&emit_with_progressive(
305
+ // Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
306
+ body.push_str(&emit_with_progressive(
250
307
  &result,
251
308
  &theme,
252
309
  &prog,
253
310
  OutputMode::Scoped,
254
311
  ));
255
312
 
256
- // 3. Fold the authored @style block (minus @theme directives).
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.
257
317
  if let Some(style) = &ast.style {
258
- let authored = strip_theme_blocks(&style.content);
259
- let authored = authored.trim();
260
- if !authored.is_empty() {
261
- match style.scope {
262
- // Scoped: it already lives in the shadow <style>; pass through.
263
- SfcStyleScope::Scoped => {
264
- out.push_str("/* authored @style (scoped) */\n");
265
- out.push_str(authored);
266
- out.push('\n');
267
- }
268
- // Global ($global): passed through unscoped (edge E6). The
269
- // compiler hoists this out of the shadow root.
270
- SfcStyleScope::Global => {
271
- out.push_str("/* authored @style ($global unscoped) */\n");
272
- out.push_str(authored);
273
- 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
+ }
274
337
  }
275
338
  }
276
339
  }
277
340
  }
278
341
 
279
- 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)
280
353
  }
281
354
 
282
355
  /// Remove `@theme { ... }` blocks from style content (they become host tokens,
283
356
  /// not raw CSS).
284
- 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> {
285
363
  let mut out = String::new();
286
364
  let mut rest = style_content;
287
365
  while let Some(at) = rest.find("@theme") {
288
366
  out.push_str(&rest[..at]);
289
367
  let after = &rest[at + "@theme".len()..];
290
368
  let Some(open) = after.find('{') else {
291
- // Malformed keep the rest verbatim and stop.
292
- out.push_str(&rest[at..]);
293
- 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
+ });
294
374
  };
295
375
  let body_start = open + 1;
296
376
  let mut depth = 1u32;
@@ -311,5 +391,5 @@ fn strip_theme_blocks(style_content: &str) -> String {
311
391
  rest = &after[end + 1..];
312
392
  }
313
393
  out.push_str(rest);
314
- out
394
+ Ok(out)
315
395
  }