@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
package/README.md CHANGED
@@ -51,7 +51,7 @@ import { defineConfig } from 'vite'
51
51
  export default defineConfig({
52
52
  plugins: [
53
53
  viteAihuPlugin({
54
- css: { shadowMode: 'none' }, // ← REQUIRED for utility classes
54
+ css: { shadowMode: 'none' }, // ← styles this component + external light-DOM children
55
55
  }),
56
56
  ],
57
57
  })
@@ -70,17 +70,22 @@ bun add @aihu/css-engine
70
70
  }
71
71
  ```
72
72
 
73
- That's it. After `bun run build`, `dist/assets/index-*.css` contains the
74
- emitted rules (`.flex{display:flex}`, `.gap-6{gap:1.5rem}`, etc.).
73
+ That's it. With the default `shadowMode: 'open'`, the scoped rules
74
+ (`.flex{display:flex}`, `.gap-6{gap:1.5rem}`, etc.) fold into each component's
75
+ shadow `<style>`. With `shadowMode: 'none'` (the example above) they instead
76
+ land in `dist/assets/index-*.css` after `bun run build`.
75
77
 
76
- #### Why `shadowMode: 'none'`?
78
+ #### When do you need `shadowMode: 'none'`?
77
79
 
78
- Utility classes rely on the global cascade. With the default `shadowMode: 'open'`
79
- each component lives behind its own shadow root, so global rules can't reach
80
- template elements. Switching to `'none'` mounts components without a shadow
81
- root and lets the bundled CSS reach them. Pick `'open'` or `'closed'` only if
82
- you're authoring components with hand-written `@style { ... }` blocks instead
83
- of utility classes.
80
+ Scoped utility classes work fine behind a shadow root (the default `'open'`):
81
+ `@aihu/css-engine` compiles each SFC's classes to a per-component stylesheet and
82
+ folds it into that component's shadow `<style>`. It is scoped by design and does
83
+ **not** rely on the global cascade.
84
+
85
+ Use `'none'` only when you want the utility CSS in the light DOM — for example to
86
+ style external / slotted child elements that live outside your component's shadow
87
+ root, or to emit a single global sheet. (Truly global frameworks like Tailwind,
88
+ UnoCSS, or Pico do require `'none'`, but `@aihu/css-engine` does not.)
84
89
 
85
90
  #### Style packs vs the utility scanner — they are separate
86
91
 
@@ -88,7 +93,7 @@ Two distinct things ship in `@aihu/css-engine`:
88
93
 
89
94
  | Concern | What you do | Output |
90
95
  |---|---|---|
91
- | **Utility scanner** (this section) | Install package + set `css.shadowMode: 'none'` | Per-class rules folded into the Vite CSS bundle |
96
+ | **Utility scanner** (this section) | Install the package (works in any shadow mode; default `'open'` folds into the shadow style) | Per-component scoped rules (or the Vite CSS bundle under `'none'`) |
92
97
  | **Theme packs** (color tokens, fonts) | `import "@aihu/css-engine/styles/aihu-graphite.css"` in your entry | `--color-*` / `--font-*` CSS custom properties at `:root` |
93
98
 
94
99
  Theme packs are plain CSS imports — they emit token variables, not utility
@@ -193,7 +198,7 @@ npm install @aihu/css-engine
193
198
  bun add @aihu/css-engine
194
199
  ```
195
200
 
196
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
201
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
197
202
 
198
203
  <!-- END_AUTOGEN: install -->
199
204
 
@@ -204,12 +209,12 @@ bun add @aihu/css-engine
204
209
 
205
210
  | | |
206
211
  |---|---|
207
- | **Version** | `0.2.5` |
212
+ | **Version** | `0.4.0` |
208
213
  | **Tier** | D — Compiler — CSS engine (Tailwind v4 hard fork, WC-native scoped output) |
209
214
  | **Published files** | 5 entries |
210
215
  | **License** | MIT |
211
216
 
212
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
217
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
213
218
 
214
219
  <!-- END_AUTOGEN: stats -->
215
220
 
@@ -228,7 +233,7 @@ bun add @aihu/css-engine
228
233
  | `./runtime/cn` | `./dist/runtime/cn.js` | `—` |
229
234
  | `./runtime/progressive` | `./dist/runtime/progressive.js` | `—` |
230
235
 
231
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
236
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
232
237
 
233
238
  <!-- END_AUTOGEN: exports -->
234
239
 
@@ -243,12 +248,12 @@ bun add @aihu/css-engine
243
248
 
244
249
  **Optional dependencies (platform-specific):**
245
250
 
246
- - `@aihu/css-engine-darwin-arm64` — `0.1.2`
247
- - `@aihu/css-engine-darwin-x64` — `0.1.2`
248
- - `@aihu/css-engine-linux-x64-gnu` — `0.1.2`
249
- - `@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`
250
255
 
251
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
256
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
252
257
 
253
258
  <!-- END_AUTOGEN: deps -->
254
259
 
@@ -261,7 +266,7 @@ bun add @aihu/css-engine
261
266
  - [@aihu/compiler](../compiler)
262
267
  - [Aihu framework root](../../README.md)
263
268
 
264
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
269
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
265
270
 
266
271
  <!-- END_AUTOGEN: see-also -->
267
272
 
@@ -272,6 +277,6 @@ bun add @aihu/css-engine
272
277
 
273
278
  MIT — see [LICENSE](../../LICENSE).
274
279
 
275
- <sub><i>Auto-generated against `@aihu/css-engine@0.2.5`.</i></sub>
280
+ <sub><i>Auto-generated against `@aihu/css-engine@0.4.0`.</i></sub>
276
281
 
277
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.