@aihu/css-engine 0.1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/crates/aihu-css-core/Cargo.toml +22 -0
  4. package/crates/aihu-css-core/src/ast.rs +173 -0
  5. package/crates/aihu-css-core/src/bin/main.rs +73 -0
  6. package/crates/aihu-css-core/src/cache.rs +182 -0
  7. package/crates/aihu-css-core/src/emit.rs +236 -0
  8. package/crates/aihu-css-core/src/features/anchor.rs +41 -0
  9. package/crates/aihu-css-core/src/features/mod.rs +33 -0
  10. package/crates/aihu-css-core/src/features/popover.rs +40 -0
  11. package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
  12. package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
  13. package/crates/aihu-css-core/src/lib.rs +67 -0
  14. package/crates/aihu-css-core/src/progressive.rs +200 -0
  15. package/crates/aihu-css-core/src/scanner.rs +235 -0
  16. package/crates/aihu-css-core/src/theme.rs +179 -0
  17. package/crates/aihu-css-core/src/tokens.rs +470 -0
  18. package/crates/aihu-css-core/src/variants.rs +124 -0
  19. package/crates/aihu-css-core/tests/cache.rs +71 -0
  20. package/crates/aihu-css-core/tests/emit.rs +148 -0
  21. package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
  22. package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
  23. package/crates/aihu-css-core/tests/scanner.rs +99 -0
  24. package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
  25. package/crates/aihu-css-core/tests/snapshot.rs +24 -0
  26. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
  27. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
  28. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
  29. package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
  30. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
  31. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
  32. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
  33. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
  34. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
  35. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
  36. package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
  37. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
  38. package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
  39. package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
  40. package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
  41. package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
  42. package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
  43. package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
  44. package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
  45. package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
  46. package/crates/aihu-css-core/tests/tokens.rs +79 -0
  47. package/dist/index.d.ts +76 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +120 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/runtime/cn.d.ts +14 -0
  52. package/dist/runtime/cn.d.ts.map +1 -0
  53. package/dist/runtime/cn.js +107 -0
  54. package/dist/runtime/cn.js.map +1 -0
  55. package/dist/runtime/progressive.d.ts +54 -0
  56. package/dist/runtime/progressive.d.ts.map +1 -0
  57. package/dist/runtime/progressive.js +132 -0
  58. package/dist/runtime/progressive.js.map +1 -0
  59. package/package.json +54 -0
  60. package/styles/aihu-default.css +73 -0
  61. package/styles/aihu-graphite.css +71 -0
@@ -0,0 +1,236 @@
1
+ //! `emit.rs` — scoped-output emitter (Plan 2 Tasks 4, 5, 6).
2
+ //!
3
+ //! Turns a scanned utility set into CSS. Two modes:
4
+ //!
5
+ //! - [`OutputMode::Flat`] — Plan 1 back-compat: `.class { … }` global-ish rules.
6
+ //! - [`OutputMode::Scoped`] — the new default for `compile_sfc`. Every rule
7
+ //! lives inside the SFC's shadow root (the compiler folds the output into the
8
+ //! component's `<style>`), so there is NO global utility stylesheet. Class
9
+ //! selectors inside a shadow `<style>` only match that shadow tree — that IS
10
+ //! the scoping mechanism (per spec §6.3). We also fold the authored `@style`
11
+ //! block (scoped folded in; `$global` passed through) and the theme tokens.
12
+ //!
13
+ //! Variant resolution (Tasks 5/6) happens here: each scanned token is split via
14
+ //! `variants::split_variants`, the base utility is compiled via `tokens`, then
15
+ //! the variants wrap/append to the selector. Dark-mode variants (`dark:`,
16
+ //! `host-context-dark:`) emit a custom-property cascade — NEVER
17
+ //! `:host-context()` (Firefox workaround, `decision-firefox-host-context-workaround`).
18
+
19
+ use crate::ast::{SfcAst, SfcStyleScope};
20
+ use crate::progressive::ProgressiveRegistry;
21
+ use crate::scanner::{scan, ScanResult};
22
+ use crate::theme::{extract_theme_blocks, ThemeRegistry};
23
+ use crate::tokens::utility_to_css;
24
+ use crate::variants::{split_variants, Variant};
25
+
26
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
+ pub enum OutputMode {
28
+ Flat,
29
+ Scoped,
30
+ }
31
+
32
+ /// CSS-escape a class name for use in a selector (`bg-[#fff]` → `bg-\[\#fff\]`).
33
+ fn escape_class(class: &str) -> String {
34
+ let mut out = String::with_capacity(class.len() + 4);
35
+ for c in class.chars() {
36
+ if matches!(c, '[' | ']' | '#' | '(' | ')' | '.' | '%' | '/' | ':' | ',') {
37
+ out.push('\\');
38
+ }
39
+ out.push(c);
40
+ }
41
+ out
42
+ }
43
+
44
+ /// If `token`'s leading prefix names a registered progressive feature, return
45
+ /// its `(prefix, base)` split. `view-transition:slide` → `("view-transition",
46
+ /// "slide")`; `text-balance:` → `("text-balance", "")`.
47
+ fn progressive_split<'a>(token: &'a str, prog: &ProgressiveRegistry) -> Option<(&'a str, &'a str)> {
48
+ let colon = token.find(':')?;
49
+ let prefix = &token[..colon];
50
+ if prog.is_feature(prefix) {
51
+ Some((prefix, &token[colon + 1..]))
52
+ } else {
53
+ None
54
+ }
55
+ }
56
+
57
+ /// Compile a single scanned token (which may carry variant prefixes) into a CSS
58
+ /// rule string, or `None` if the base utility is unknown.
59
+ ///
60
+ /// Progressive-feature prefixes (`view-transition:`, `anchor:`, `popover:`,
61
+ /// `text-balance:`) are routed to the [`ProgressiveRegistry`] emitter (Plan 3
62
+ /// Task 4) instead of the standard selector path.
63
+ fn emit_token(token: &str, theme: &ThemeRegistry, prog: &ProgressiveRegistry) -> Option<String> {
64
+ if let Some((prefix, base)) = progressive_split(token, prog) {
65
+ return prog.emit(prefix, base);
66
+ }
67
+
68
+ let (variants, base) = split_variants(token);
69
+ let body = utility_to_css(&base)?;
70
+ let class_sel = format!(".{}", escape_class(token));
71
+
72
+ // The base (innermost) selector and declaration body.
73
+ let mut selector = class_sel;
74
+ let mut media: Option<String> = None;
75
+ let mut dark_cascade = false;
76
+
77
+ for v in &variants {
78
+ match v {
79
+ Variant::Host => selector = format!(":host({selector})"),
80
+ Variant::Slotted => selector = format!("::slotted({selector})"),
81
+ Variant::SlottedTag(tag) => selector = format!("::slotted({tag}{selector})"),
82
+ Variant::Part(name) => selector = format!("::part({name})"),
83
+ Variant::Pseudo(pc) => selector = format!("{selector}:{pc}"),
84
+ Variant::ArbitrarySelector(sel) => {
85
+ // `[&>div]:` → substitute `&` for the base selector.
86
+ selector = sel.replace('&', &selector);
87
+ }
88
+ Variant::Breakpoint(bp) => {
89
+ if let Some(min) = theme.breakpoint(bp) {
90
+ media = Some(format!("(min-width: {min})"));
91
+ }
92
+ }
93
+ Variant::Dark | Variant::HostContextDark => {
94
+ dark_cascade = true;
95
+ }
96
+ }
97
+ }
98
+
99
+ let rule = if dark_cascade {
100
+ // Firefox-safe dark cascade: gate the rule on the consumer's dark flag
101
+ // (a `data-theme="dark"` host attr or a `.dark` root class) rather than
102
+ // the host-context pseudo (unsupported in Firefox). Consumer contract:
103
+ // set `data-theme="dark"` on the host element OR add `.dark` to :root,
104
+ // and define the dark token values there. The dark variant's rule then
105
+ // only applies under those scopes.
106
+ format!(
107
+ "/* dark cascade (Firefox-safe; see decision-firefox-host-context-workaround) */\n\
108
+ :host([data-theme=\"dark\"]) {selector}, \
109
+ :root.dark {selector} {{ {body} }}\n"
110
+ )
111
+ } else {
112
+ format!("{selector} {{ {body} }}\n")
113
+ };
114
+
115
+ Some(match media {
116
+ Some(q) => format!("@media {q} {{\n{rule}}}\n"),
117
+ None => rule,
118
+ })
119
+ }
120
+
121
+ /// Emit CSS for a scanned utility set in the given mode.
122
+ pub fn emit(result: &ScanResult, theme: &ThemeRegistry, mode: OutputMode) -> String {
123
+ emit_with_progressive(result, theme, &ProgressiveRegistry::with_builtins(), mode)
124
+ }
125
+
126
+ /// As [`emit`], but with an explicit [`ProgressiveRegistry`] (so callers can
127
+ /// share one registry across an SFC compile).
128
+ pub fn emit_with_progressive(
129
+ result: &ScanResult,
130
+ theme: &ThemeRegistry,
131
+ prog: &ProgressiveRegistry,
132
+ mode: OutputMode,
133
+ ) -> String {
134
+ let mut out = String::new();
135
+ for token in &result.utilities {
136
+ match mode {
137
+ OutputMode::Flat => {
138
+ // Flat back-compat: only plain utilities, no variant wrapping.
139
+ if let Some(body) = utility_to_css(token) {
140
+ out.push_str(&format!(".{token} {{ {body} }}\n"));
141
+ }
142
+ }
143
+ OutputMode::Scoped => {
144
+ if let Some(rule) = emit_token(token, theme, prog) {
145
+ out.push_str(&rule);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ out
151
+ }
152
+
153
+ /// Compile a full SFC AST to scoped CSS: theme tokens (`:host`-level custom
154
+ /// props) + scanned utility rules + the folded authored `@style` block.
155
+ pub fn emit_sfc_scoped(ast: &SfcAst) -> String {
156
+ let mut theme = ThemeRegistry::with_aihu_defaults();
157
+
158
+ // Parse @theme directives from the authored style block first so utilities
159
+ // and breakpoints see overrides.
160
+ if let Some(style) = &ast.style {
161
+ let theme_bodies = extract_theme_blocks(&style.content);
162
+ if !theme_bodies.is_empty() {
163
+ theme.apply_theme_block(&theme_bodies);
164
+ }
165
+ }
166
+
167
+ let result = scan(ast);
168
+ let prog = ProgressiveRegistry::with_builtins();
169
+ let mut out = String::new();
170
+
171
+ // 1. Theme tokens at :host so var(--color-*) resolves inside the shadow.
172
+ out.push_str(&theme.emit_host_tokens());
173
+
174
+ // 2. Scanned utility rules (scoped) — progressive prefixes routed via `prog`.
175
+ out.push_str(&emit_with_progressive(&result, &theme, &prog, OutputMode::Scoped));
176
+
177
+ // 3. Fold the authored @style block (minus @theme directives).
178
+ if let Some(style) = &ast.style {
179
+ let authored = strip_theme_blocks(&style.content);
180
+ let authored = authored.trim();
181
+ if !authored.is_empty() {
182
+ match style.scope {
183
+ // Scoped: it already lives in the shadow <style>; pass through.
184
+ SfcStyleScope::Scoped => {
185
+ out.push_str("/* authored @style (scoped) */\n");
186
+ out.push_str(authored);
187
+ out.push('\n');
188
+ }
189
+ // Global ($global): passed through unscoped (edge E6). The
190
+ // compiler hoists this out of the shadow root.
191
+ SfcStyleScope::Global => {
192
+ out.push_str("/* authored @style ($global — unscoped) */\n");
193
+ out.push_str(authored);
194
+ out.push('\n');
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ out
201
+ }
202
+
203
+ /// Remove `@theme { ... }` blocks from style content (they become host tokens,
204
+ /// not raw CSS).
205
+ fn strip_theme_blocks(style_content: &str) -> String {
206
+ let mut out = String::new();
207
+ let mut rest = style_content;
208
+ while let Some(at) = rest.find("@theme") {
209
+ out.push_str(&rest[..at]);
210
+ let after = &rest[at + "@theme".len()..];
211
+ let Some(open) = after.find('{') else {
212
+ // Malformed — keep the rest verbatim and stop.
213
+ out.push_str(&rest[at..]);
214
+ return out;
215
+ };
216
+ let body_start = open + 1;
217
+ let mut depth = 1u32;
218
+ let mut end = body_start;
219
+ for (i, c) in after[body_start..].char_indices() {
220
+ match c {
221
+ '{' => depth += 1,
222
+ '}' => {
223
+ depth -= 1;
224
+ if depth == 0 {
225
+ end = body_start + i;
226
+ break;
227
+ }
228
+ }
229
+ _ => {}
230
+ }
231
+ }
232
+ rest = &after[end + 1..];
233
+ }
234
+ out.push_str(rest);
235
+ out
236
+ }
@@ -0,0 +1,41 @@
1
+ //! `anchor:` — CSS anchor positioning with a JS fallback (Plan 3 Task 6).
2
+ //!
3
+ //! Emits `anchor-name` / `position-anchor` CSS gated behind `@supports
4
+ //! (anchor-name: --a)`. `js_fallback()` returns `"anchorFallback"` — the
5
+ //! `@aihu/css-engine/runtime/progressive` shim positions the element with a
6
+ //! tiny hand-written floating-ui-style shim when native CSS anchor positioning
7
+ //! is unsupported. The shim (~2 KB) is SHARED with `popover:` (Task 7).
8
+
9
+ use crate::progressive::ProgressiveFeature;
10
+
11
+ /// `anchor:<name>` → CSS anchor-positioning, `@supports`-gated, with a JS shim.
12
+ pub struct Anchor;
13
+
14
+ impl ProgressiveFeature for Anchor {
15
+ fn prefix(&self) -> &'static str {
16
+ "anchor"
17
+ }
18
+
19
+ fn supports_condition(&self) -> Option<&'static str> {
20
+ Some("anchor-name: --a")
21
+ }
22
+
23
+ fn emit_css(&self, base: &str) -> String {
24
+ // `anchor:tooltip` declares an anchor name + binds positioning to it.
25
+ let name = if base.is_empty() { "anchor" } else { base };
26
+ let class = if base.is_empty() {
27
+ "anchor".to_string()
28
+ } else {
29
+ format!("anchor\\:{base}")
30
+ };
31
+ format!(
32
+ ".{class} {{ anchor-name: --{name}; position-anchor: --{name}; }}"
33
+ )
34
+ }
35
+
36
+ fn js_fallback(&self) -> Option<&'static str> {
37
+ // Native anchor positioning is gated; when absent, the runtime shim
38
+ // (`anchorFallback`) positions the element with JS.
39
+ Some("anchorFallback")
40
+ }
41
+ }
@@ -0,0 +1,33 @@
1
+ //! `features/` — the built-in progressive features (Plan 3 Tasks 5–8).
2
+ //!
3
+ //! Each module implements [`crate::progressive::ProgressiveFeature`] for one
4
+ //! forward-looking CSS feature. They are registered into the default
5
+ //! [`crate::progressive::ProgressiveRegistry`] via [`register_builtins`].
6
+ //!
7
+ //! | Feature | `@supports` gate | JS fallback |
8
+ //! |-------------------|-------------------------------|--------------------|
9
+ //! | `view-transition:`| `view-transition-name: none` | none (CSS-only) |
10
+ //! | `anchor:` | `anchor-name: --a` | `anchorFallback` |
11
+ //! | `popover:` | `selector(:popover-open)` | `popoverFallback` |
12
+ //! | `text-balance:` | none | none (CSS-only) |
13
+
14
+ use crate::progressive::ProgressiveRegistry;
15
+
16
+ pub mod anchor;
17
+ pub mod popover;
18
+ pub mod text_balance;
19
+ pub mod view_transition;
20
+
21
+ pub use anchor::Anchor;
22
+ pub use popover::Popover;
23
+ pub use text_balance::TextBalance;
24
+ pub use view_transition::ViewTransition;
25
+
26
+ /// Register every built-in progressive feature into `registry`. Called by
27
+ /// [`ProgressiveRegistry::with_builtins`](crate::progressive::ProgressiveRegistry::with_builtins).
28
+ pub fn register_builtins(registry: &mut ProgressiveRegistry) {
29
+ registry.register(Box::new(ViewTransition));
30
+ registry.register(Box::new(Anchor));
31
+ registry.register(Box::new(Popover));
32
+ registry.register(Box::new(TextBalance));
33
+ }
@@ -0,0 +1,40 @@
1
+ //! `popover:` — Popover API with a portal+positioning fallback (Plan 3 Task 7).
2
+ //!
3
+ //! Emits popover CSS gated behind `@supports selector(:popover-open)`.
4
+ //! `js_fallback()` returns `"popoverFallback"` — the
5
+ //! `@aihu/css-engine/runtime/progressive` shim emulates the top layer with a
6
+ //! portal and positions the panel using the SAME floating-ui shim as `anchor:`
7
+ //! (no duplication — keeps the `progressive` sub-export under its 3 KB budget).
8
+
9
+ use crate::progressive::ProgressiveFeature;
10
+
11
+ /// `popover:<name>` → popover CSS, `@supports`-gated, with a portal JS fallback.
12
+ pub struct Popover;
13
+
14
+ impl ProgressiveFeature for Popover {
15
+ fn prefix(&self) -> &'static str {
16
+ "popover"
17
+ }
18
+
19
+ fn supports_condition(&self) -> Option<&'static str> {
20
+ Some("selector(:popover-open)")
21
+ }
22
+
23
+ fn emit_css(&self, base: &str) -> String {
24
+ // The popover panel's open-state styling; native top-layer when supported.
25
+ let class = if base.is_empty() {
26
+ "popover".to_string()
27
+ } else {
28
+ format!("popover\\:{base}")
29
+ };
30
+ format!(
31
+ ".{class}:popover-open {{ position: fixed; margin: 0; inset: auto; }}"
32
+ )
33
+ }
34
+
35
+ fn js_fallback(&self) -> Option<&'static str> {
36
+ // When the Popover API is unavailable, the runtime shim portals the panel
37
+ // to the top layer and positions it with the shared floating-ui code.
38
+ Some("popoverFallback")
39
+ }
40
+ }
@@ -0,0 +1,36 @@
1
+ //! `text-balance:` — the simplest possible progressive feature (Plan 3 Task 8).
2
+ //!
3
+ //! Emits a single `text-wrap: balance` declaration. `supports_condition()` is
4
+ //! `None` (no `@supports` gate) and `js_fallback()` is `None` (no JS): browsers
5
+ //! that don't understand the `balance` value silently ignore it — standard CSS
6
+ //! forward-compatibility. One declaration, no gate, no runtime cost.
7
+
8
+ use crate::progressive::ProgressiveFeature;
9
+
10
+ /// `text-balance:` → `text-wrap: balance`. No gate, no JS.
11
+ pub struct TextBalance;
12
+
13
+ impl ProgressiveFeature for TextBalance {
14
+ fn prefix(&self) -> &'static str {
15
+ "text-balance"
16
+ }
17
+
18
+ fn supports_condition(&self) -> Option<&'static str> {
19
+ // No gate — unsupported browsers silently ignore the unknown value.
20
+ None
21
+ }
22
+
23
+ fn emit_css(&self, base: &str) -> String {
24
+ let class = if base.is_empty() {
25
+ "text-balance".to_string()
26
+ } else {
27
+ format!("text-balance\\:{base}")
28
+ };
29
+ format!(".{class} {{ text-wrap: balance; }}")
30
+ }
31
+
32
+ fn js_fallback(&self) -> Option<&'static str> {
33
+ // CSS-only — no runtime fallback.
34
+ None
35
+ }
36
+ }
@@ -0,0 +1,38 @@
1
+ //! `view-transition:` — the simplest progressive feature (Plan 3 Task 5).
2
+ //!
3
+ //! Emits `view-transition-name` gated behind `@supports (view-transition-name:
4
+ //! none)`. CSS-only: `js_fallback()` is `None`, so when the View Transitions API
5
+ //! is unsupported the browser silently skips the transition (no JS, no error,
6
+ //! no runtime cost). Per spec §6.7 this is the cheapest progressive feature.
7
+
8
+ use crate::progressive::ProgressiveFeature;
9
+
10
+ /// `view-transition:<name>` → a `view-transition-name` declaration, `@supports`-gated.
11
+ pub struct ViewTransition;
12
+
13
+ impl ProgressiveFeature for ViewTransition {
14
+ fn prefix(&self) -> &'static str {
15
+ "view-transition"
16
+ }
17
+
18
+ fn supports_condition(&self) -> Option<&'static str> {
19
+ Some("view-transition-name: none")
20
+ }
21
+
22
+ fn emit_css(&self, base: &str) -> String {
23
+ // `view-transition:hero` → `.view-transition\:hero { view-transition-name: hero; }`
24
+ // An empty base (`view-transition:`) defaults to `auto`.
25
+ let name = if base.is_empty() { "auto" } else { base };
26
+ let class = if base.is_empty() {
27
+ "view-transition".to_string()
28
+ } else {
29
+ format!("view-transition\\:{base}")
30
+ };
31
+ format!(".{class} {{ view-transition-name: {name}; }}")
32
+ }
33
+
34
+ fn js_fallback(&self) -> Option<&'static str> {
35
+ // CSS-only — unsupported browsers silently skip the transition.
36
+ None
37
+ }
38
+ }
@@ -0,0 +1,67 @@
1
+ //! aihu-css-core — CSS engine bootstrap.
2
+ //!
3
+ //! See `docs/superpowers/specs/2026-05-10-aihu-css-engine-and-primitives-design.md`
4
+ //! for the full design. This bootstrap implementation supports a fixed subset
5
+ //! of utility classes (see tokens.rs); Plan 2 wires the AST scanner; Plan 3
6
+ //! adds variants and progressive features.
7
+
8
+ pub mod ast;
9
+ pub mod cache;
10
+ pub mod emit;
11
+ pub mod features;
12
+ pub mod progressive;
13
+ pub mod scanner;
14
+ pub mod theme;
15
+ pub mod tokens;
16
+ pub mod variants;
17
+
18
+ pub use ast::{parse_ast, AstError, SfcAst, SfcAttr, SfcNode, SfcStyleScope};
19
+ pub use cache::{hash_ast, CssCache};
20
+ pub use emit::{emit, emit_sfc_scoped, OutputMode};
21
+ pub use progressive::{ProgressiveFeature, ProgressiveRegistry};
22
+ pub use scanner::{scan, scan_ast, ScanResult};
23
+ pub use theme::ThemeRegistry;
24
+ pub use variants::{split_variants, Variant};
25
+
26
+ /// Compile a list of utility class names into CSS rules.
27
+ /// Each known class becomes `.class-name { <body> }`. Unknown classes are skipped.
28
+ ///
29
+ /// # Example
30
+ /// ```
31
+ /// use aihu_css_core::compile_classes;
32
+ /// let css = compile_classes(&["bg-primary".to_string(), "p-4".to_string()]);
33
+ /// assert!(css.contains(".bg-primary"));
34
+ /// assert!(css.contains(".p-4"));
35
+ /// ```
36
+ pub fn compile_classes(classes: &[String]) -> String {
37
+ let mut output = String::new();
38
+ for class in classes {
39
+ if let Some(body) = tokens::utility_to_css(class) {
40
+ output.push('.');
41
+ output.push_str(class);
42
+ output.push_str(" { ");
43
+ output.push_str(&body);
44
+ output.push_str(" }\n");
45
+ }
46
+ }
47
+ output
48
+ }
49
+
50
+ /// Compile a parsed `.aihu` SFC AST into CSS by scanning its template for
51
+ /// utility classes and emitting one rule per known utility.
52
+ ///
53
+ /// This is the Plan 2 flat-mode entry; [`compile_sfc_scoped`] wraps the output
54
+ /// in shadow-DOM scope (the new default). `compile_classes` stays for
55
+ /// back-compat.
56
+ pub fn compile_sfc(ast: &SfcAst) -> String {
57
+ let classes = scan_ast(ast);
58
+ compile_classes(&classes.into_iter().collect::<Vec<_>>())
59
+ }
60
+
61
+ /// Compile a parsed `.aihu` SFC AST into scoped, shadow-DOM-embedded CSS:
62
+ /// `:host`-level theme tokens, variant-resolved utility rules, and the folded
63
+ /// authored `@style` block. This is the production entry consumed by the TS
64
+ /// bridge / `aihu-css-compile --ast-json`.
65
+ pub fn compile_sfc_scoped(ast: &SfcAst) -> String {
66
+ emit_sfc_scoped(ast)
67
+ }