@aihu/css-engine 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/crates/aihu-css-core/src/apply.rs +314 -0
- package/crates/aihu-css-core/src/bin/main.rs +8 -7
- package/crates/aihu-css-core/src/cache.rs +8 -5
- package/crates/aihu-css-core/src/emit.rs +110 -30
- package/crates/aihu-css-core/src/lib.rs +10 -2
- package/crates/aihu-css-core/src/palette.rs +301 -0
- package/crates/aihu-css-core/src/style_parser.rs +587 -0
- package/crates/aihu-css-core/src/tokens.rs +625 -29
- package/crates/aihu-css-core/src/variants.rs +154 -7
- package/crates/aihu-css-core/tests/apply.rs +203 -0
- package/crates/aihu-css-core/tests/apply_regression.rs +150 -0
- package/crates/aihu-css-core/tests/binary_error.rs +61 -0
- package/crates/aihu-css-core/tests/cache.rs +8 -8
- package/crates/aihu-css-core/tests/emit.rs +95 -36
- package/crates/aihu-css-core/tests/parity.rs +274 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +8 -8
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +49 -11
- package/crates/aihu-css-core/tests/snapshots/apply__apply_inside_nested_rule.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_utility_in_apply.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/apply__arbitrary_value_variant_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__base_utility_inlines_declarations.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/apply__dark_variant_cascade_in_apply.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__data_attribute_variant.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multi_token_apply.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/apply__multiple_apply_directives_per_rule.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__responsive_variant_wraps_media.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/apply__single_variant_lifts_to_nested_rule.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +1 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +6 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +5 -2
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +1 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +2 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +1 -0
- package/crates/aihu-css-core/tests/style_parser.rs +257 -0
- package/crates/aihu-css-core/tests/tokens.rs +52 -0
- package/dist/index.d.ts +0 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
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.
|
|
201
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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.
|
|
212
|
+
| **Version** | `0.4.1` |
|
|
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.
|
|
217
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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.
|
|
236
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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.
|
|
252
|
-
- `@aihu/css-engine-darwin-x64` — `0.1.
|
|
253
|
-
- `@aihu/css-engine-linux-x64-gnu` — `0.1.
|
|
254
|
-
- `@aihu/css-engine-win32-x64-msvc` — `0.1.
|
|
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.
|
|
256
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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.
|
|
269
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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.
|
|
280
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.4.1`.</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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
246
|
-
|
|
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
|
-
//
|
|
249
|
-
|
|
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
|
-
//
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
return
|
|
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
|
}
|