@aihu/css-engine 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -18
- package/crates/aihu-css-core/src/emit.rs +87 -8
- package/crates/aihu-css-core/src/lib.rs +5 -0
- package/crates/aihu-css-core/src/theme.rs +14 -0
- package/crates/aihu-css-core/src/tokens.rs +579 -8
- package/crates/aihu-css-core/src/variants.rs +101 -0
- package/crates/aihu-css-core/tests/emit.rs +208 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +35 -1
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_animate_spin_hoists_keyframes.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_divide_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_space_y_nested_rule.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_transition_and_transform.snap +26 -0
- package/crates/aihu-css-core/tests/tokens.rs +474 -7
- package/dist/runtime/cn.js +13 -0
- package/dist/runtime/cn.js.map +1 -1
- package/package.json +1 -1
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' }, // ←
|
|
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.
|
|
74
|
-
|
|
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
|
-
####
|
|
78
|
+
#### When do you need `shadowMode: 'none'`?
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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.
|
|
201
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.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.
|
|
212
|
+
| **Version** | `0.3.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.
|
|
217
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.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.
|
|
236
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
|
|
232
237
|
|
|
233
238
|
<!-- END_AUTOGEN: exports -->
|
|
234
239
|
|
|
@@ -248,7 +253,7 @@ bun add @aihu/css-engine
|
|
|
248
253
|
- `@aihu/css-engine-linux-x64-gnu` — `0.1.2`
|
|
249
254
|
- `@aihu/css-engine-win32-x64-msvc` — `0.1.2`
|
|
250
255
|
|
|
251
|
-
<sub><i>Auto-generated against `@aihu/css-engine@0.
|
|
256
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.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.
|
|
269
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.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.
|
|
280
|
+
<sub><i>Auto-generated against `@aihu/css-engine@0.3.0`.</i></sub>
|
|
276
281
|
|
|
277
282
|
<!-- END_AUTOGEN: license -->
|
|
@@ -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 {
|
|
@@ -33,7 +33,10 @@ pub enum OutputMode {
|
|
|
33
33
|
fn escape_class(class: &str) -> String {
|
|
34
34
|
let mut out = String::with_capacity(class.len() + 4);
|
|
35
35
|
for c in class.chars() {
|
|
36
|
-
if matches!(
|
|
36
|
+
if matches!(
|
|
37
|
+
c,
|
|
38
|
+
'[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',' | '@' | '=' | '"'
|
|
39
|
+
) {
|
|
37
40
|
out.push('\\');
|
|
38
41
|
}
|
|
39
42
|
out.push(c);
|
|
@@ -71,7 +74,11 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
|
|
|
71
74
|
|
|
72
75
|
// The base (innermost) selector and declaration body.
|
|
73
76
|
let mut selector = class_sel;
|
|
74
|
-
|
|
77
|
+
// Wrapping at-rule (e.g. `@media (min-width: …)` for breakpoints or
|
|
78
|
+
// `@container (min-width: …)` for container queries). Generalized from the
|
|
79
|
+
// old `media: Option<String>` slot so both `@media` and `@container` wrap
|
|
80
|
+
// the rule uniformly: `<at-rule> { <rule> }`.
|
|
81
|
+
let mut at_rule: Option<String> = None;
|
|
75
82
|
let mut dark_cascade = false;
|
|
76
83
|
|
|
77
84
|
for v in &variants {
|
|
@@ -85,9 +92,43 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
|
|
|
85
92
|
// `[&>div]:` → substitute `&` for the base selector.
|
|
86
93
|
selector = sel.replace('&', &selector);
|
|
87
94
|
}
|
|
95
|
+
Variant::Group(Some(state)) => {
|
|
96
|
+
// `group-hover:bg-x` → `.group:hover .group-hover\:bg-x`.
|
|
97
|
+
// Prepend a descendant-combinator ancestor selector: the rule
|
|
98
|
+
// applies to the element bearing this class when an ancestor
|
|
99
|
+
// marked `class="group"` is in `:<state>`. Within a shadow root
|
|
100
|
+
// both the marker and the styled element live in the same tree,
|
|
101
|
+
// so the class selectors match per spec §6.3 scoping.
|
|
102
|
+
selector = format!(".group:{state} {selector}");
|
|
103
|
+
}
|
|
104
|
+
Variant::Peer(Some(state)) => {
|
|
105
|
+
// `peer-checked:bg-x` → `.peer:checked ~ .peer-checked\:bg-x`.
|
|
106
|
+
// Prepend a subsequent-sibling-combinator selector: the rule
|
|
107
|
+
// applies when a PRIOR sibling marked `class="peer"` is in
|
|
108
|
+
// `:<state>`. CSS can only look backward to earlier siblings,
|
|
109
|
+
// so `peer` must appear before the styled element in source.
|
|
110
|
+
selector = format!(".peer:{state} ~ {selector}");
|
|
111
|
+
}
|
|
112
|
+
// Bare `group`/`peer` never reach here (they are marker utilities,
|
|
113
|
+
// not variant prefixes); a `None` state is unreachable but handled
|
|
114
|
+
// defensively as a no-op so the base selector is emitted unchanged.
|
|
115
|
+
Variant::Group(None) | Variant::Peer(None) => {}
|
|
116
|
+
// aria-*/data-* attribute variants compile to an attribute selector
|
|
117
|
+
// appended to the base: `aria-checked:` → `.cls[aria-checked="true"]`,
|
|
118
|
+
// `data-[state=open]:` → `.cls[data-state="open"]`. A keyword data-*
|
|
119
|
+
// (`data-active:`) emits a presence selector `[data-active]`.
|
|
120
|
+
Variant::Aria(m) => selector = format!("{selector}{}", attr_selector("aria", m)),
|
|
121
|
+
Variant::Data(m) => selector = format!("{selector}{}", attr_selector("data", m)),
|
|
88
122
|
Variant::Breakpoint(bp) => {
|
|
89
123
|
if let Some(min) = theme.breakpoint(bp) {
|
|
90
|
-
|
|
124
|
+
at_rule = Some(format!("@media (min-width: {min})"));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Container queries wrap the rule in an `@container` at-rule keyed on
|
|
128
|
+
// the container breakpoint scale (mirrors `breakpoint()`).
|
|
129
|
+
Variant::Container(bp) => {
|
|
130
|
+
if let Some(min) = theme.container_breakpoint(bp) {
|
|
131
|
+
at_rule = Some(format!("@container (min-width: {min})"));
|
|
91
132
|
}
|
|
92
133
|
}
|
|
93
134
|
Variant::Dark | Variant::HostContextDark => {
|
|
@@ -112,12 +153,41 @@ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) ->
|
|
|
112
153
|
format!("{selector} {{ {body} }}\n")
|
|
113
154
|
};
|
|
114
155
|
|
|
115
|
-
|
|
116
|
-
Some(
|
|
156
|
+
let rule = match at_rule {
|
|
157
|
+
Some(at) => format!("{at} {{\n{rule}}}\n"),
|
|
158
|
+
None => rule,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Hoist the @keyframes an `animate-*` utility depends on as a top-level
|
|
162
|
+
// sibling rule (it cannot live nested inside the selector body). Re-emitting
|
|
163
|
+
// an identical block is idempotent in CSS, so per-occurrence emission is
|
|
164
|
+
// safe. `base` is the variant-stripped class (e.g. `animate-spin`).
|
|
165
|
+
Some(match animation_keyframes(&base) {
|
|
166
|
+
Some(kf) => format!("{rule}{kf}\n"),
|
|
117
167
|
None => rule,
|
|
118
168
|
})
|
|
119
169
|
}
|
|
120
170
|
|
|
171
|
+
/// Build an attribute-selector fragment for an `aria-*`/`data-*` variant.
|
|
172
|
+
///
|
|
173
|
+
/// `attr_selector("aria", Name{checked, true})` → `[aria-checked="true"]`;
|
|
174
|
+
/// `attr_selector("data", NameValue{state, open})` → `[data-state="open"]`;
|
|
175
|
+
/// `attr_selector("data", Name{active, false})` → `[data-active]` (presence).
|
|
176
|
+
fn attr_selector(family: &str, m: &AttrMatch) -> String {
|
|
177
|
+
match m {
|
|
178
|
+
AttrMatch::Name { name, imply_true } => {
|
|
179
|
+
if *imply_true {
|
|
180
|
+
format!("[{family}-{name}=\"true\"]")
|
|
181
|
+
} else {
|
|
182
|
+
format!("[{family}-{name}]")
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
AttrMatch::NameValue { name, value } => {
|
|
186
|
+
format!("[{family}-{name}=\"{value}\"]")
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
121
191
|
/// Emit CSS for a scanned utility set in the given mode.
|
|
122
192
|
pub fn emit(result: &ScanResult, theme: &ThemeRegistry, mode: OutputMode) -> String {
|
|
123
193
|
emit_with_progressive(result, theme, &ProgressiveRegistry::with_builtins(), mode)
|
|
@@ -138,6 +208,10 @@ pub fn emit_with_progressive(
|
|
|
138
208
|
// Flat back-compat: only plain utilities, no variant wrapping.
|
|
139
209
|
if let Some(body) = utility_to_css(token) {
|
|
140
210
|
out.push_str(&format!(".{token} {{ {body} }}\n"));
|
|
211
|
+
if let Some(kf) = animation_keyframes(token) {
|
|
212
|
+
out.push_str(kf);
|
|
213
|
+
out.push('\n');
|
|
214
|
+
}
|
|
141
215
|
}
|
|
142
216
|
}
|
|
143
217
|
OutputMode::Scoped => {
|
|
@@ -172,7 +246,12 @@ pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
|
|
|
172
246
|
out.push_str(&theme.emit_host_tokens());
|
|
173
247
|
|
|
174
248
|
// 2. Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
|
|
175
|
-
out.push_str(&emit_with_progressive(
|
|
249
|
+
out.push_str(&emit_with_progressive(
|
|
250
|
+
&result,
|
|
251
|
+
&theme,
|
|
252
|
+
&prog,
|
|
253
|
+
OutputMode::Scoped,
|
|
254
|
+
));
|
|
176
255
|
|
|
177
256
|
// 3. Fold the authored @style block (minus @theme directives).
|
|
178
257
|
if let Some(style) = &ast.style {
|
|
@@ -42,6 +42,11 @@ pub fn compile_classes(classes: &[String]) -> String {
|
|
|
42
42
|
output.push_str(" { ");
|
|
43
43
|
output.push_str(&body);
|
|
44
44
|
output.push_str(" }\n");
|
|
45
|
+
// Hoist the matching @keyframes as a sibling rule (animate-* only).
|
|
46
|
+
if let Some(kf) = tokens::animation_keyframes(class) {
|
|
47
|
+
output.push_str(kf);
|
|
48
|
+
output.push('\n');
|
|
49
|
+
}
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
output
|
|
@@ -70,6 +70,20 @@ impl ThemeRegistry {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/// Resolve a container-query breakpoint (`@sm`/`@md`/…) to its min-width.
|
|
74
|
+
/// Mirrors [`breakpoint`] but uses Tailwind's container-query scale (which
|
|
75
|
+
/// differs from the viewport breakpoint scale): `@sm`=24rem … `@2xl`=42rem.
|
|
76
|
+
pub fn container_breakpoint(&self, name: &str) -> Option<&'static str> {
|
|
77
|
+
match name {
|
|
78
|
+
"sm" => Some("24rem"),
|
|
79
|
+
"md" => Some("28rem"),
|
|
80
|
+
"lg" => Some("32rem"),
|
|
81
|
+
"xl" => Some("36rem"),
|
|
82
|
+
"2xl" => Some("42rem"),
|
|
83
|
+
_ => None,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
/// Merge an `@theme { ... }` block's tokens over the current registry.
|
|
74
88
|
/// Returns the number of tokens registered/overridden.
|
|
75
89
|
pub fn apply_theme_block(&mut self, theme_body: &str) -> usize {
|