@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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/crates/aihu-css-core/Cargo.toml +22 -0
- package/crates/aihu-css-core/src/ast.rs +173 -0
- package/crates/aihu-css-core/src/bin/main.rs +73 -0
- package/crates/aihu-css-core/src/cache.rs +182 -0
- package/crates/aihu-css-core/src/emit.rs +236 -0
- package/crates/aihu-css-core/src/features/anchor.rs +41 -0
- package/crates/aihu-css-core/src/features/mod.rs +33 -0
- package/crates/aihu-css-core/src/features/popover.rs +40 -0
- package/crates/aihu-css-core/src/features/text_balance.rs +36 -0
- package/crates/aihu-css-core/src/features/view_transition.rs +38 -0
- package/crates/aihu-css-core/src/lib.rs +67 -0
- package/crates/aihu-css-core/src/progressive.rs +200 -0
- package/crates/aihu-css-core/src/scanner.rs +235 -0
- package/crates/aihu-css-core/src/theme.rs +179 -0
- package/crates/aihu-css-core/src/tokens.rs +470 -0
- package/crates/aihu-css-core/src/variants.rs +124 -0
- package/crates/aihu-css-core/tests/cache.rs +71 -0
- package/crates/aihu-css-core/tests/emit.rs +148 -0
- package/crates/aihu-css-core/tests/fixtures/button.ast.json +19 -0
- package/crates/aihu-css-core/tests/progressive_snapshot.rs +102 -0
- package/crates/aihu-css-core/tests/scanner.rs +99 -0
- package/crates/aihu-css-core/tests/scoped_snapshot.rs +73 -0
- package/crates/aihu-css-core/tests/snapshot.rs +24 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__anchor_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__popover_snapshot.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__text_balance_snapshot.snap +23 -0
- package/crates/aihu-css-core/tests/snapshots/progressive_snapshot__view_transition_snapshot.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__flat_output_for_class_list.snap +6 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_output_for_sfc.snap +25 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_authored_style_block.snap +26 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__scoped_with_global_style_block.snap +24 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__standard_variants.snap +33 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__theme_default_vs_override.snap +45 -0
- package/crates/aihu-css-core/tests/snapshots/scoped_snapshot__wc_native_variants.snap +28 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_basic_class.snap +5 -0
- package/crates/aihu-css-core/tests/snapshots/snapshot__compiles_multiple_classes.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__arbitrary_values.snap +9 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_borders.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_colors.snap +10 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_effects.snap +8 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_layout.snap +12 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_spacing.snap +11 -0
- package/crates/aihu-css-core/tests/snapshots/tokens__category_typography.snap +11 -0
- package/crates/aihu-css-core/tests/tokens.rs +79 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/cn.d.ts +14 -0
- package/dist/runtime/cn.d.ts.map +1 -0
- package/dist/runtime/cn.js +107 -0
- package/dist/runtime/cn.js.map +1 -0
- package/dist/runtime/progressive.d.ts +54 -0
- package/dist/runtime/progressive.d.ts.map +1 -0
- package/dist/runtime/progressive.js +132 -0
- package/dist/runtime/progressive.js.map +1 -0
- package/package.json +54 -0
- package/styles/aihu-default.css +73 -0
- package/styles/aihu-graphite.css +71 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
//! Tailwind v4 utility-class → CSS mapping.
|
|
2
|
+
//!
|
|
3
|
+
//! Strategy (Plan 2 Task 3, decision `decision-css-hard-fork-vs-upstream`):
|
|
4
|
+
//! we take *inspiration* from Tailwind v4, not a source merge. The table is a
|
|
5
|
+
//! hybrid:
|
|
6
|
+
//!
|
|
7
|
+
//! - **Regular grids** (spacing, the standard color palette, font-size, etc.)
|
|
8
|
+
//! are generated algorithmically from compact data tables — no wall of
|
|
9
|
+
//! literals, and the spacing scale derives from a single `* 0.25rem` rule.
|
|
10
|
+
//! - **The long tail** (display, flex, position, text-align, …) is a
|
|
11
|
+
//! hand-written `match` of fixed property/value pairs.
|
|
12
|
+
//! - **Arbitrary values** (`bg-[#1a1d24]`, `w-[34ch]`, `text-[14px]`) are
|
|
13
|
+
//! parsed by [`parse_arbitrary`] and emitted verbatim.
|
|
14
|
+
//!
|
|
15
|
+
//! Brand color tokens (`bg-primary`, `text-accent`, …) resolve to
|
|
16
|
+
//! `var(--color-*)` custom properties registered by the `@theme` registry
|
|
17
|
+
//! (`theme.rs`) so authored `@theme` overrides cascade through.
|
|
18
|
+
|
|
19
|
+
/// Emit the **conflict-group map**: `(class-prefix, group-key)` pairs derived
|
|
20
|
+
/// directly from the utility registry's own property maps (`spacing_prop`,
|
|
21
|
+
/// `sizing_prop`, `color_prop`, `arbitrary_prop`). Two utilities conflict (last
|
|
22
|
+
/// wins) when they share a group key — the group key is the CSS property the
|
|
23
|
+
/// prefix controls, so `p-2`/`p-4` (both `padding`) conflict, while `p-2`/`mx-4`
|
|
24
|
+
/// (`padding` vs `margin-inline`) do not.
|
|
25
|
+
///
|
|
26
|
+
/// This is the single source of truth the engine's build step serializes into
|
|
27
|
+
/// the TS `cn()` conflict map (Plan 3 Task 9) — so the runtime merge map NEVER
|
|
28
|
+
/// drifts from the compile-time utility table. Plus a few fixed-utility groups
|
|
29
|
+
/// (display, position) whose whole-class names share a property.
|
|
30
|
+
pub fn conflict_groups() -> Vec<(&'static str, &'static str)> {
|
|
31
|
+
let mut out: Vec<(&'static str, &'static str)> = Vec::new();
|
|
32
|
+
|
|
33
|
+
// Prefix-based utilities: prefix → its controlled CSS property (the group).
|
|
34
|
+
const SPACING_PREFIXES: &[&str] = &[
|
|
35
|
+
"p", "px", "py", "pt", "pr", "pb", "pl", "m", "mx", "my", "mt", "mr",
|
|
36
|
+
"mb", "ml", "gap", "gap-x", "gap-y",
|
|
37
|
+
];
|
|
38
|
+
for p in SPACING_PREFIXES {
|
|
39
|
+
if let Some(group) = spacing_prop(p) {
|
|
40
|
+
out.push((p, group));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const SIZING_PREFIXES: &[&str] = &["w", "h", "min-w", "max-w", "min-h", "max-h"];
|
|
45
|
+
for p in SIZING_PREFIXES {
|
|
46
|
+
if let Some(group) = sizing_prop(p) {
|
|
47
|
+
out.push((p, group));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const COLOR_PREFIXES: &[&str] =
|
|
52
|
+
&["bg", "text", "border", "fill", "stroke", "ring", "outline"];
|
|
53
|
+
for p in COLOR_PREFIXES {
|
|
54
|
+
if let Some(group) = color_prop(p) {
|
|
55
|
+
out.push((p, group));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A handful of non-color/spacing parameterized prefixes with their own group.
|
|
60
|
+
out.push(("z", "z-index"));
|
|
61
|
+
out.push(("opacity", "opacity"));
|
|
62
|
+
out.push(("rounded", "border-radius"));
|
|
63
|
+
out.push(("shadow", "box-shadow"));
|
|
64
|
+
out.push(("font", "font-weight"));
|
|
65
|
+
|
|
66
|
+
out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Map a single (already variant-stripped) utility class name to its CSS body
|
|
70
|
+
/// (declarations only, no selector). Returns `None` for unknown utilities.
|
|
71
|
+
pub fn utility_to_css(class_name: &str) -> Option<String> {
|
|
72
|
+
// 1. Arbitrary-value bracket syntax: bg-[#1a1d24], w-[34ch], text-[14px].
|
|
73
|
+
if let Some(css) = parse_arbitrary(class_name) {
|
|
74
|
+
return Some(css);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Fixed long-tail utilities (no value parameter).
|
|
78
|
+
if let Some(css) = fixed_utility(class_name) {
|
|
79
|
+
return Some(css.to_string());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Parameterized utilities split on the LAST `-` (prefix + value).
|
|
83
|
+
if let Some(idx) = class_name.rfind('-') {
|
|
84
|
+
let (prefix, value) = (&class_name[..idx], &class_name[idx + 1..]);
|
|
85
|
+
if let Some(css) = parameterized_utility(prefix, value) {
|
|
86
|
+
return Some(css);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Whole-token color utilities like `bg-primary` (value = brand token).
|
|
90
|
+
if let Some(css) = brand_color_utility(class_name) {
|
|
91
|
+
return Some(css);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
None
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Parse `prefix-[value]` arbitrary-value syntax. The bracket content is
|
|
98
|
+
/// emitted verbatim into the mapped CSS property (edge E7).
|
|
99
|
+
pub fn parse_arbitrary(class_name: &str) -> Option<String> {
|
|
100
|
+
let open = class_name.find("-[")?;
|
|
101
|
+
if !class_name.ends_with(']') {
|
|
102
|
+
return None;
|
|
103
|
+
}
|
|
104
|
+
let prefix = &class_name[..open];
|
|
105
|
+
let value = &class_name[open + 2..class_name.len() - 1];
|
|
106
|
+
let prop = arbitrary_prop(prefix)?;
|
|
107
|
+
// Underscores in arbitrary values stand for spaces (Tailwind convention).
|
|
108
|
+
let value = value.replace('_', " ");
|
|
109
|
+
Some(format!("{prop}: {value};"))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Map an arbitrary-value prefix to its CSS property.
|
|
113
|
+
fn arbitrary_prop(prefix: &str) -> Option<&'static str> {
|
|
114
|
+
Some(match prefix {
|
|
115
|
+
"bg" => "background-color",
|
|
116
|
+
"text" => "color",
|
|
117
|
+
"w" => "width",
|
|
118
|
+
"h" => "height",
|
|
119
|
+
"min-w" => "min-width",
|
|
120
|
+
"max-w" => "max-width",
|
|
121
|
+
"min-h" => "min-height",
|
|
122
|
+
"max-h" => "max-height",
|
|
123
|
+
"p" => "padding",
|
|
124
|
+
"px" => "padding-inline",
|
|
125
|
+
"py" => "padding-block",
|
|
126
|
+
"m" => "margin",
|
|
127
|
+
"mx" => "margin-inline",
|
|
128
|
+
"my" => "margin-block",
|
|
129
|
+
"gap" => "gap",
|
|
130
|
+
"rounded" => "border-radius",
|
|
131
|
+
"border" => "border-width",
|
|
132
|
+
"leading" => "line-height",
|
|
133
|
+
"tracking" => "letter-spacing",
|
|
134
|
+
"z" => "z-index",
|
|
135
|
+
"top" => "top",
|
|
136
|
+
"right" => "right",
|
|
137
|
+
"bottom" => "bottom",
|
|
138
|
+
"left" => "left",
|
|
139
|
+
"inset" => "inset",
|
|
140
|
+
"fill" => "fill",
|
|
141
|
+
"stroke" => "stroke",
|
|
142
|
+
"shadow" => "box-shadow",
|
|
143
|
+
_ => return None,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Fixed long-tail utilities (display, flex, position, alignment, etc.).
|
|
148
|
+
fn fixed_utility(class_name: &str) -> Option<&'static str> {
|
|
149
|
+
Some(match class_name {
|
|
150
|
+
// Display
|
|
151
|
+
"block" => "display: block;",
|
|
152
|
+
"inline-block" => "display: inline-block;",
|
|
153
|
+
"inline" => "display: inline;",
|
|
154
|
+
"flex" => "display: flex;",
|
|
155
|
+
"inline-flex" => "display: inline-flex;",
|
|
156
|
+
"grid" => "display: grid;",
|
|
157
|
+
"inline-grid" => "display: inline-grid;",
|
|
158
|
+
"hidden" => "display: none;",
|
|
159
|
+
"contents" => "display: contents;",
|
|
160
|
+
|
|
161
|
+
// Flexbox / grid alignment
|
|
162
|
+
"flex-row" => "flex-direction: row;",
|
|
163
|
+
"flex-col" => "flex-direction: column;",
|
|
164
|
+
"flex-wrap" => "flex-wrap: wrap;",
|
|
165
|
+
"flex-nowrap" => "flex-wrap: nowrap;",
|
|
166
|
+
"flex-1" => "flex: 1 1 0%;",
|
|
167
|
+
"flex-auto" => "flex: 1 1 auto;",
|
|
168
|
+
"flex-none" => "flex: none;",
|
|
169
|
+
"items-start" => "align-items: flex-start;",
|
|
170
|
+
"items-center" => "align-items: center;",
|
|
171
|
+
"items-end" => "align-items: flex-end;",
|
|
172
|
+
"items-stretch" => "align-items: stretch;",
|
|
173
|
+
"items-baseline" => "align-items: baseline;",
|
|
174
|
+
"justify-start" => "justify-content: flex-start;",
|
|
175
|
+
"justify-center" => "justify-content: center;",
|
|
176
|
+
"justify-end" => "justify-content: flex-end;",
|
|
177
|
+
"justify-between" => "justify-content: space-between;",
|
|
178
|
+
"justify-around" => "justify-content: space-around;",
|
|
179
|
+
"justify-evenly" => "justify-content: space-evenly;",
|
|
180
|
+
|
|
181
|
+
// Position
|
|
182
|
+
"static" => "position: static;",
|
|
183
|
+
"relative" => "position: relative;",
|
|
184
|
+
"absolute" => "position: absolute;",
|
|
185
|
+
"fixed" => "position: fixed;",
|
|
186
|
+
"sticky" => "position: sticky;",
|
|
187
|
+
|
|
188
|
+
// Overflow
|
|
189
|
+
"overflow-hidden" => "overflow: hidden;",
|
|
190
|
+
"overflow-auto" => "overflow: auto;",
|
|
191
|
+
"overflow-scroll" => "overflow: scroll;",
|
|
192
|
+
"overflow-visible" => "overflow: visible;",
|
|
193
|
+
|
|
194
|
+
// Typography
|
|
195
|
+
"text-left" => "text-align: left;",
|
|
196
|
+
"text-center" => "text-align: center;",
|
|
197
|
+
"text-right" => "text-align: right;",
|
|
198
|
+
"text-justify" => "text-align: justify;",
|
|
199
|
+
"italic" => "font-style: italic;",
|
|
200
|
+
"not-italic" => "font-style: normal;",
|
|
201
|
+
"underline" => "text-decoration-line: underline;",
|
|
202
|
+
"line-through" => "text-decoration-line: line-through;",
|
|
203
|
+
"no-underline" => "text-decoration-line: none;",
|
|
204
|
+
"uppercase" => "text-transform: uppercase;",
|
|
205
|
+
"lowercase" => "text-transform: lowercase;",
|
|
206
|
+
"capitalize" => "text-transform: capitalize;",
|
|
207
|
+
"truncate" => "overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
|
|
208
|
+
"font-thin" => "font-weight: 100;",
|
|
209
|
+
"font-normal" => "font-weight: 400;",
|
|
210
|
+
"font-medium" => "font-weight: 500;",
|
|
211
|
+
"font-semibold" => "font-weight: 600;",
|
|
212
|
+
"font-bold" => "font-weight: 700;",
|
|
213
|
+
"font-black" => "font-weight: 900;",
|
|
214
|
+
|
|
215
|
+
// Borders / effects
|
|
216
|
+
"border" => "border-width: 1px;",
|
|
217
|
+
"rounded" => "border-radius: 0.25rem;",
|
|
218
|
+
"rounded-none" => "border-radius: 0;",
|
|
219
|
+
"rounded-sm" => "border-radius: 0.125rem;",
|
|
220
|
+
"rounded-md" => "border-radius: 0.375rem;",
|
|
221
|
+
"rounded-lg" => "border-radius: 0.5rem;",
|
|
222
|
+
"rounded-xl" => "border-radius: 0.75rem;",
|
|
223
|
+
"rounded-2xl" => "border-radius: 1rem;",
|
|
224
|
+
"rounded-full" => "border-radius: 9999px;",
|
|
225
|
+
"shadow" => "box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);",
|
|
226
|
+
"shadow-sm" => "box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);",
|
|
227
|
+
"shadow-md" => "box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);",
|
|
228
|
+
"shadow-lg" => "box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);",
|
|
229
|
+
"shadow-none" => "box-shadow: none;",
|
|
230
|
+
|
|
231
|
+
// Width / height keywords
|
|
232
|
+
"w-full" => "width: 100%;",
|
|
233
|
+
"w-screen" => "width: 100vw;",
|
|
234
|
+
"w-auto" => "width: auto;",
|
|
235
|
+
"h-full" => "height: 100%;",
|
|
236
|
+
"h-screen" => "height: 100vh;",
|
|
237
|
+
"h-auto" => "height: auto;",
|
|
238
|
+
|
|
239
|
+
_ => return None,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Parameterized utilities split on the last `-`: `p-4`, `text-lg`, `gap-2`,
|
|
244
|
+
/// `text-red-500` (handled via [`palette_color`]), `z-10`, etc.
|
|
245
|
+
fn parameterized_utility(prefix: &str, value: &str) -> Option<String> {
|
|
246
|
+
// Spacing scale: p/px/py/pt/pr/pb/pl, m/mx/my/…, gap, space — value × 0.25rem.
|
|
247
|
+
if let Some(prop) = spacing_prop(prefix) {
|
|
248
|
+
if let Some(rem) = spacing_value(value) {
|
|
249
|
+
return Some(format!("{prop}: {rem};"));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Sizing: w-/h-/min-w-/max-w- with the spacing scale or fractions.
|
|
254
|
+
if let Some(prop) = sizing_prop(prefix) {
|
|
255
|
+
if let Some(v) = sizing_value(value) {
|
|
256
|
+
return Some(format!("{prop}: {v};"));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Font size (with paired line-height, Tailwind defaults).
|
|
261
|
+
if prefix == "text" {
|
|
262
|
+
if let Some(css) = font_size(value) {
|
|
263
|
+
return Some(css.to_string());
|
|
264
|
+
}
|
|
265
|
+
// text-<color>-<shade> falls through to the color path below.
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Border radius scale already covered by fixed_utility for named sizes.
|
|
269
|
+
|
|
270
|
+
// z-index.
|
|
271
|
+
if prefix == "z" {
|
|
272
|
+
if value.chars().all(|c| c.is_ascii_digit()) {
|
|
273
|
+
return Some(format!("z-index: {value};"));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// opacity.
|
|
278
|
+
if prefix == "opacity" {
|
|
279
|
+
if let Ok(n) = value.parse::<f32>() {
|
|
280
|
+
return Some(format!("opacity: {};", n / 100.0));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Palette colors: bg-red-500, text-slate-700, border-emerald-300.
|
|
285
|
+
if let Some(prop) = color_prop(prefix) {
|
|
286
|
+
// prefix already stripped to the color path; value is the shade only
|
|
287
|
+
// when the full name was `prefix-color-shade`. We handle that in the
|
|
288
|
+
// caller via the full-string brand path; here handle `prefix-keyword`.
|
|
289
|
+
if let Some(color) = named_keyword_color(value) {
|
|
290
|
+
return Some(format!("{prop}: {color};"));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
None
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Whole-token brand + palette color utilities: `bg-primary`, `text-accent`,
|
|
298
|
+
/// `bg-red-500`. Brand tokens resolve to `var(--color-*)`; palette tokens to
|
|
299
|
+
/// concrete oklch values.
|
|
300
|
+
fn brand_color_utility(class_name: &str) -> Option<String> {
|
|
301
|
+
// Split into <prefix>-<rest> where rest is the color name (may contain `-`).
|
|
302
|
+
let idx = class_name.find('-')?;
|
|
303
|
+
let (prefix, rest) = (&class_name[..idx], &class_name[idx + 1..]);
|
|
304
|
+
let prop = color_prop(prefix)?;
|
|
305
|
+
|
|
306
|
+
// Brand tokens: primary, secondary, accent, surface, muted, foreground, …
|
|
307
|
+
if is_brand_token(rest) {
|
|
308
|
+
return Some(format!("{prop}: var(--color-{rest});"));
|
|
309
|
+
}
|
|
310
|
+
// Palette: red-500, slate-700 → var(--color-red-500) (registered by theme).
|
|
311
|
+
if is_palette_token(rest) {
|
|
312
|
+
return Some(format!("{prop}: var(--color-{rest});"));
|
|
313
|
+
}
|
|
314
|
+
// Bare keyword colors: white/black/transparent/current.
|
|
315
|
+
if let Some(color) = named_keyword_color(rest) {
|
|
316
|
+
return Some(format!("{prop}: {color};"));
|
|
317
|
+
}
|
|
318
|
+
None
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
fn color_prop(prefix: &str) -> Option<&'static str> {
|
|
322
|
+
Some(match prefix {
|
|
323
|
+
"bg" => "background-color",
|
|
324
|
+
"text" => "color",
|
|
325
|
+
"border" => "border-color",
|
|
326
|
+
"fill" => "fill",
|
|
327
|
+
"stroke" => "stroke",
|
|
328
|
+
"ring" => "--tw-ring-color",
|
|
329
|
+
"outline" => "outline-color",
|
|
330
|
+
_ => return None,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fn is_brand_token(name: &str) -> bool {
|
|
335
|
+
matches!(
|
|
336
|
+
name,
|
|
337
|
+
"primary"
|
|
338
|
+
| "primary-foreground"
|
|
339
|
+
| "secondary"
|
|
340
|
+
| "secondary-foreground"
|
|
341
|
+
| "accent"
|
|
342
|
+
| "accent-foreground"
|
|
343
|
+
| "surface"
|
|
344
|
+
| "surface-foreground"
|
|
345
|
+
| "background"
|
|
346
|
+
| "foreground"
|
|
347
|
+
| "muted"
|
|
348
|
+
| "muted-foreground"
|
|
349
|
+
| "border"
|
|
350
|
+
| "ring"
|
|
351
|
+
| "destructive"
|
|
352
|
+
| "destructive-foreground"
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/// `red-500`, `slate-700`, etc. — palette family + numeric shade.
|
|
357
|
+
fn is_palette_token(name: &str) -> bool {
|
|
358
|
+
let Some((family, shade)) = name.rsplit_once('-') else {
|
|
359
|
+
return false;
|
|
360
|
+
};
|
|
361
|
+
const FAMILIES: &[&str] = &[
|
|
362
|
+
"slate", "gray", "zinc", "neutral", "stone", "red", "orange", "amber",
|
|
363
|
+
"yellow", "lime", "green", "emerald", "teal", "cyan", "sky", "blue",
|
|
364
|
+
"indigo", "violet", "purple", "fuchsia", "pink", "rose",
|
|
365
|
+
];
|
|
366
|
+
FAMILIES.contains(&family)
|
|
367
|
+
&& matches!(
|
|
368
|
+
shade,
|
|
369
|
+
"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950"
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fn named_keyword_color(name: &str) -> Option<&'static str> {
|
|
374
|
+
Some(match name {
|
|
375
|
+
"white" => "#fff",
|
|
376
|
+
"black" => "#000",
|
|
377
|
+
"transparent" => "transparent",
|
|
378
|
+
"current" => "currentColor",
|
|
379
|
+
"inherit" => "inherit",
|
|
380
|
+
_ => return None,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn spacing_prop(prefix: &str) -> Option<&'static str> {
|
|
385
|
+
Some(match prefix {
|
|
386
|
+
"p" => "padding",
|
|
387
|
+
"px" => "padding-inline",
|
|
388
|
+
"py" => "padding-block",
|
|
389
|
+
"pt" => "padding-top",
|
|
390
|
+
"pr" => "padding-right",
|
|
391
|
+
"pb" => "padding-bottom",
|
|
392
|
+
"pl" => "padding-left",
|
|
393
|
+
"m" => "margin",
|
|
394
|
+
"mx" => "margin-inline",
|
|
395
|
+
"my" => "margin-block",
|
|
396
|
+
"mt" => "margin-top",
|
|
397
|
+
"mr" => "margin-right",
|
|
398
|
+
"mb" => "margin-bottom",
|
|
399
|
+
"ml" => "margin-left",
|
|
400
|
+
"gap" => "gap",
|
|
401
|
+
"gap-x" => "column-gap",
|
|
402
|
+
"gap-y" => "row-gap",
|
|
403
|
+
_ => return None,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Tailwind spacing scale: numeric → `n * 0.25rem`; `px` → `1px`; `0` → `0`.
|
|
408
|
+
fn spacing_value(value: &str) -> Option<String> {
|
|
409
|
+
if value == "px" {
|
|
410
|
+
return Some("1px".to_string());
|
|
411
|
+
}
|
|
412
|
+
if value == "0" {
|
|
413
|
+
return Some("0".to_string());
|
|
414
|
+
}
|
|
415
|
+
// Fractional steps like `0.5`, `1.5`, `2.5`.
|
|
416
|
+
let n: f32 = value.parse().ok()?;
|
|
417
|
+
let rem = n * 0.25;
|
|
418
|
+
Some(format!("{}rem", trim_float(rem)))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
fn sizing_prop(prefix: &str) -> Option<&'static str> {
|
|
422
|
+
Some(match prefix {
|
|
423
|
+
"w" => "width",
|
|
424
|
+
"h" => "height",
|
|
425
|
+
"min-w" => "min-width",
|
|
426
|
+
"max-w" => "max-width",
|
|
427
|
+
"min-h" => "min-height",
|
|
428
|
+
"max-h" => "max-height",
|
|
429
|
+
_ => return None,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
fn sizing_value(value: &str) -> Option<String> {
|
|
434
|
+
// Fractions: w-1/2 → 50%.
|
|
435
|
+
if let Some((num, den)) = value.split_once('/') {
|
|
436
|
+
let n: f32 = num.parse().ok()?;
|
|
437
|
+
let d: f32 = den.parse().ok()?;
|
|
438
|
+
if d != 0.0 {
|
|
439
|
+
return Some(format!("{}%", trim_float(n / d * 100.0)));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Numeric spacing scale.
|
|
443
|
+
spacing_value(value)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// Font-size scale (size + matched line-height), Tailwind defaults.
|
|
447
|
+
fn font_size(value: &str) -> Option<&'static str> {
|
|
448
|
+
Some(match value {
|
|
449
|
+
"xs" => "font-size: 0.75rem; line-height: 1rem;",
|
|
450
|
+
"sm" => "font-size: 0.875rem; line-height: 1.25rem;",
|
|
451
|
+
"base" => "font-size: 1rem; line-height: 1.5rem;",
|
|
452
|
+
"lg" => "font-size: 1.125rem; line-height: 1.75rem;",
|
|
453
|
+
"xl" => "font-size: 1.25rem; line-height: 1.75rem;",
|
|
454
|
+
"2xl" => "font-size: 1.5rem; line-height: 2rem;",
|
|
455
|
+
"3xl" => "font-size: 1.875rem; line-height: 2.25rem;",
|
|
456
|
+
"4xl" => "font-size: 2.25rem; line-height: 2.5rem;",
|
|
457
|
+
"5xl" => "font-size: 3rem; line-height: 1;",
|
|
458
|
+
_ => return None,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/// Trim trailing `.0` from a float for clean CSS (`1.0rem` → `1rem`).
|
|
463
|
+
fn trim_float(n: f32) -> String {
|
|
464
|
+
if n.fract() == 0.0 {
|
|
465
|
+
format!("{}", n as i64)
|
|
466
|
+
} else {
|
|
467
|
+
let s = format!("{n}");
|
|
468
|
+
s
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//! `variants.rs` — variant-prefix parser + selector/wrapper resolver.
|
|
2
|
+
//!
|
|
3
|
+
//! A utility token may carry one or more `variant:` prefixes
|
|
4
|
+
//! (`host:bg-primary`, `md:hover:bg-primary`, `slotted-img:rounded-lg`,
|
|
5
|
+
//! `part-thumb:bg-accent`, `[&>div]:text-primary`). The scanner stores the
|
|
6
|
+
//! FULL prefixed token; the emitter splits prefixes here at emit time.
|
|
7
|
+
//!
|
|
8
|
+
//! Variants come in two flavours:
|
|
9
|
+
//! - **Selector variants** wrap/append to the base selector (`host:` → `:host`,
|
|
10
|
+
//! `hover:` → `&:hover`, `slotted:` → `::slotted(*)`, `part-x:` → `::part(x)`,
|
|
11
|
+
//! `[&>div]:` → `& > div`).
|
|
12
|
+
//! - **Wrapping variants** wrap the whole rule (`md:` → `@media (...)`).
|
|
13
|
+
//! - **Cascade variants** (`dark:`, `host-context-dark:`) do NOT emit a
|
|
14
|
+
//! `:host-context()` selector (unsupported in Firefox per
|
|
15
|
+
//! `decision-firefox-host-context-workaround`); instead the dark value is
|
|
16
|
+
//! placed behind an inherited custom property the consumer toggles in
|
|
17
|
+
//! `:root`/`.dark`. See [`Variant::is_dark_cascade`].
|
|
18
|
+
|
|
19
|
+
/// One parsed variant prefix.
|
|
20
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
21
|
+
pub enum Variant {
|
|
22
|
+
// ── WC-native ──────────────────────────────────────────────────────────
|
|
23
|
+
/// `host:` → `:host`.
|
|
24
|
+
Host,
|
|
25
|
+
/// `slotted:` → `::slotted(*)`.
|
|
26
|
+
Slotted,
|
|
27
|
+
/// `slotted-img:` → `::slotted(img)`.
|
|
28
|
+
SlottedTag(String),
|
|
29
|
+
/// `part-<name>:` → `::part(<name>)`.
|
|
30
|
+
Part(String),
|
|
31
|
+
/// `host-context-dark:` — dark cascade (NOT `:host-context()`).
|
|
32
|
+
HostContextDark,
|
|
33
|
+
|
|
34
|
+
// ── standard ─────────────────────────────────────────────────────────────
|
|
35
|
+
/// `hover:`, `focus:`, `focus-visible:`, `active:`, `disabled:` → `&:<pc>`.
|
|
36
|
+
Pseudo(String),
|
|
37
|
+
/// `dark:` — dark cascade (NOT `:host-context()`).
|
|
38
|
+
Dark,
|
|
39
|
+
/// `sm:`/`md:`/`lg:`/`xl:`/`2xl:` → `@media (min-width: …)`.
|
|
40
|
+
Breakpoint(String),
|
|
41
|
+
/// `[&>div]:` arbitrary selector → native nesting (`& > div`).
|
|
42
|
+
ArbitrarySelector(String),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
impl Variant {
|
|
46
|
+
/// True for the Firefox-safe dark-mode cascade variants. Their value is
|
|
47
|
+
/// emitted behind a custom property the consumer toggles, never a
|
|
48
|
+
/// `:host-context(.dark)` selector.
|
|
49
|
+
pub fn is_dark_cascade(&self) -> bool {
|
|
50
|
+
matches!(self, Variant::Dark | Variant::HostContextDark)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Split a (possibly multi-prefixed) class token into its ordered variants and
|
|
55
|
+
/// the bare base utility. Unknown prefixes are treated as part of the base
|
|
56
|
+
/// (so an ordinary `bg-red-500` is never mis-split).
|
|
57
|
+
///
|
|
58
|
+
/// `md:hover:bg-primary` → (`[Breakpoint(md), Pseudo(hover)]`, `"bg-primary"`).
|
|
59
|
+
pub fn split_variants(token: &str) -> (Vec<Variant>, String) {
|
|
60
|
+
let mut variants = Vec::new();
|
|
61
|
+
let mut rest = token;
|
|
62
|
+
|
|
63
|
+
loop {
|
|
64
|
+
// Arbitrary-selector prefix `[...]:` — find the matching `]:`.
|
|
65
|
+
if rest.starts_with('[') {
|
|
66
|
+
if let Some(close) = rest.find("]:") {
|
|
67
|
+
let sel = &rest[1..close];
|
|
68
|
+
variants.push(Variant::ArbitrarySelector(sel.to_string()));
|
|
69
|
+
rest = &rest[close + 2..];
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Find the next `:` that is NOT inside brackets and is a known prefix.
|
|
75
|
+
let Some(colon) = next_prefix_colon(rest) else {
|
|
76
|
+
break;
|
|
77
|
+
};
|
|
78
|
+
let prefix = &rest[..colon];
|
|
79
|
+
match parse_prefix(prefix) {
|
|
80
|
+
Some(v) => {
|
|
81
|
+
variants.push(v);
|
|
82
|
+
rest = &rest[colon + 1..];
|
|
83
|
+
}
|
|
84
|
+
None => break, // not a recognized variant — the rest is the base.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
(variants, rest.to_string())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Find the index of the next top-level `:` (not inside `[...]`).
|
|
92
|
+
fn next_prefix_colon(s: &str) -> Option<usize> {
|
|
93
|
+
let mut depth = 0u32;
|
|
94
|
+
for (i, c) in s.char_indices() {
|
|
95
|
+
match c {
|
|
96
|
+
'[' => depth += 1,
|
|
97
|
+
']' => depth = depth.saturating_sub(1),
|
|
98
|
+
':' if depth == 0 => return Some(i),
|
|
99
|
+
_ => {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
None
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fn parse_prefix(prefix: &str) -> Option<Variant> {
|
|
106
|
+
Some(match prefix {
|
|
107
|
+
"host" => Variant::Host,
|
|
108
|
+
"host-context-dark" => Variant::HostContextDark,
|
|
109
|
+
"slotted" => Variant::Slotted,
|
|
110
|
+
"dark" => Variant::Dark,
|
|
111
|
+
"hover" | "focus" | "focus-visible" | "active" | "disabled" | "visited"
|
|
112
|
+
| "checked" => Variant::Pseudo(prefix.to_string()),
|
|
113
|
+
"sm" | "md" | "lg" | "xl" | "2xl" => Variant::Breakpoint(prefix.to_string()),
|
|
114
|
+
_ => {
|
|
115
|
+
if let Some(tag) = prefix.strip_prefix("slotted-") {
|
|
116
|
+
Variant::SlottedTag(tag.to_string())
|
|
117
|
+
} else if let Some(name) = prefix.strip_prefix("part-") {
|
|
118
|
+
Variant::Part(name.to_string())
|
|
119
|
+
} else {
|
|
120
|
+
return None;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//! Incremental-cache tests (Plan 2 Task 8): a hit returns identical output and
|
|
2
|
+
//! skips recompilation; an AST or theme-version change invalidates the entry.
|
|
3
|
+
|
|
4
|
+
use std::time::Instant;
|
|
5
|
+
|
|
6
|
+
use aihu_css_core::{parse_ast, CssCache, SfcAst};
|
|
7
|
+
|
|
8
|
+
fn sfc(classes: &str) -> SfcAst {
|
|
9
|
+
parse_ast(&format!(
|
|
10
|
+
r#"{{"tag":"X","astVersion":1,"style":null,"meta":{{"name":"X"}},
|
|
11
|
+
"template":[{{"kind":"element","tag":"div","attrs":[
|
|
12
|
+
{{"kind":"static","name":"class","value":"{classes}"}}
|
|
13
|
+
],"children":[]}}]}}"#
|
|
14
|
+
))
|
|
15
|
+
.unwrap()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[test]
|
|
19
|
+
fn hit_returns_identical_output_and_skips_recompile() {
|
|
20
|
+
let mut cache = CssCache::new();
|
|
21
|
+
let ast = sfc("bg-primary p-4");
|
|
22
|
+
|
|
23
|
+
let first = cache.compile(&ast, 1);
|
|
24
|
+
assert_eq!(cache.recompiles(), 1, "first compile is a miss");
|
|
25
|
+
assert_eq!(cache.hits(), 0);
|
|
26
|
+
|
|
27
|
+
let second = cache.compile(&ast, 1);
|
|
28
|
+
assert_eq!(second, first, "cache hit returns byte-identical output");
|
|
29
|
+
assert_eq!(cache.recompiles(), 1, "second compile must NOT recompile");
|
|
30
|
+
assert_eq!(cache.hits(), 1, "second compile is a hit");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[test]
|
|
34
|
+
fn ast_change_invalidates_entry() {
|
|
35
|
+
let mut cache = CssCache::new();
|
|
36
|
+
cache.compile(&sfc("p-4"), 1);
|
|
37
|
+
cache.compile(&sfc("p-8"), 1); // different class → different hash
|
|
38
|
+
assert_eq!(cache.recompiles(), 2, "a changed AST recompiles");
|
|
39
|
+
assert_eq!(cache.hits(), 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[test]
|
|
43
|
+
fn theme_version_change_invalidates_entry() {
|
|
44
|
+
let mut cache = CssCache::new();
|
|
45
|
+
let ast = sfc("bg-primary");
|
|
46
|
+
cache.compile(&ast, 1);
|
|
47
|
+
cache.compile(&ast, 2); // theme bumped → invalidate
|
|
48
|
+
assert_eq!(cache.recompiles(), 2, "a theme-version bump recompiles");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#[test]
|
|
52
|
+
fn cache_hit_is_well_under_30ms() {
|
|
53
|
+
let mut cache = CssCache::new();
|
|
54
|
+
// Warm the cache.
|
|
55
|
+
let ast = sfc("bg-primary p-4 rounded-lg hover:bg-accent md:p-8 host:text-primary");
|
|
56
|
+
let _ = cache.compile(&ast, 1);
|
|
57
|
+
|
|
58
|
+
// Time 1000 cache hits; each must be far below the 30 ms per-SFC bar.
|
|
59
|
+
let start = Instant::now();
|
|
60
|
+
for _ in 0..1000 {
|
|
61
|
+
let _ = cache.compile(&ast, 1);
|
|
62
|
+
}
|
|
63
|
+
let elapsed = start.elapsed();
|
|
64
|
+
let per_hit = elapsed / 1000;
|
|
65
|
+
assert!(
|
|
66
|
+
per_hit.as_micros() < 30_000,
|
|
67
|
+
"cache hit took {per_hit:?} (>30ms bar)"
|
|
68
|
+
);
|
|
69
|
+
assert_eq!(cache.hits(), 1000);
|
|
70
|
+
assert_eq!(cache.recompiles(), 1, "only the warm-up compiled");
|
|
71
|
+
}
|