Skip to main content

eui_core/
style_check.rs

1//! The `Box` style whitelist as a validator: `validateStyle` of
2//! `src/style.ts`, keyed off the embedded `catalog/style.json`. The regexes of
3//! the TS are small hand-written parsers here (no regex engine).
4
5use std::sync::LazyLock;
6
7use indexmap::IndexMap;
8use serde::Deserialize;
9use serde_json::Value;
10
11use crate::catalog::{is_known_token, parse_token_ref};
12use crate::generated::catalog as g;
13
14/// One `style.json` key spec.
15#[derive(Debug, Clone, Default, Deserialize)]
16pub struct KeySpec {
17    #[serde(default, rename = "type")]
18    pub type_: Option<String>,
19    #[serde(default, rename = "enum")]
20    pub enum_: Option<Vec<Value>>,
21    #[serde(default)]
22    pub group: Option<String>,
23    #[serde(default, rename = "rootOnly")]
24    pub root_only: Option<bool>,
25    #[serde(default, rename = "layoutEffect")]
26    pub layout_effect: Option<bool>,
27}
28
29#[derive(Deserialize)]
30struct StyleFile {
31    layout: IndexMap<String, KeySpec>,
32    visual: IndexMap<String, KeySpec>,
33}
34
35/// Every whitelisted key → its spec (layout keys first, then visual).
36pub static STYLE_SPECS: LazyLock<IndexMap<String, KeySpec>> = LazyLock::new(|| {
37    let file: StyleFile = serde_json::from_str(g::STYLE_JSON).expect("style.json");
38    let mut out = file.layout;
39    out.extend(file.visual);
40    out
41});
42
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct StyleIssue {
45    pub path: String,
46    pub message: String,
47}
48
49const NUMERIC_TOKEN_GROUPS: &[&str] = &["spacing", "radius", "control", "type.size", "type.lineHeight", "opacity", "border"];
50
51fn digits(s: &str) -> bool {
52    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
53}
54
55/// `\d+(\.\d+)?`
56fn decimal(s: &str) -> bool {
57    match s.split_once('.') {
58        Some((i, f)) => digits(i) && digits(f),
59        None => digits(s),
60    }
61}
62
63/// `^-?\d+(\.\d+)?(px|%)$`
64fn is_length(s: &str) -> bool {
65    let body = s.strip_prefix('-').unwrap_or(s);
66    let num = body.strip_suffix("px").or_else(|| body.strip_suffix('%'));
67    num.is_some_and(decimal)
68}
69
70/// `^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8}|[0-9a-fA-F]{3,4})$`
71pub fn is_hex_color(s: &str) -> bool {
72    s.strip_prefix('#')
73        .is_some_and(|h| matches!(h.len(), 3 | 4 | 6 | 8) && h.bytes().all(|b| b.is_ascii_hexdigit()))
74}
75
76/// `^\d+(\.\d+)?\/\d+(\.\d+)?$`
77fn is_ratio(s: &str) -> bool {
78    s.split_once('/').is_some_and(|(w, h)| decimal(w) && decimal(h))
79}
80
81pub use crate::conditions::{is_condition_key, is_media_key};
82
83/// `-?\d+(\.\d+)?`
84fn signed_decimal(s: &str) -> bool {
85    decimal(s.strip_prefix('-').unwrap_or(s))
86}
87
88/// One transform function: `translate(Xpx, Ypx)`, `scale(N)`, `rotate(Ndeg)`.
89fn transform_fn(s: &str) -> bool {
90    if let Some(args) = s.strip_prefix("translate(").and_then(|r| r.strip_suffix(')')) {
91        return args.split_once(", ").is_some_and(|(x, y)| x.strip_suffix("px").is_some_and(signed_decimal) && y.strip_suffix("px").is_some_and(signed_decimal));
92    }
93    if let Some(n) = s.strip_prefix("scale(").and_then(|r| r.strip_suffix(')')) {
94        return signed_decimal(n);
95    }
96    if let Some(n) = s.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
97        return n.strip_suffix("deg").is_some_and(signed_decimal);
98    }
99    false
100}
101
102/// `STYLE_TRANSFORM_PATTERN`: one or more transform functions separated by
103/// ONE space, no leading or trailing space.
104pub fn is_transform(s: &str) -> bool {
105    if s.is_empty() || s.starts_with(' ') || s.ends_with(' ') {
106        return false;
107    }
108    // A `translate(Xpx, Ypx)` holds a space itself: split on ") " instead.
109    let mut rest = s;
110    loop {
111        let Some(close) = rest.find(')') else { return false };
112        let (piece, after) = rest.split_at(close + 1);
113        if !transform_fn(piece) {
114            return false;
115        }
116        if after.is_empty() {
117            return true;
118        }
119        let Some(next) = after.strip_prefix(' ') else { return false };
120        if next.is_empty() || next.starts_with(' ') {
121            return false;
122        }
123        rest = next;
124    }
125}
126
127fn gradient_error(key: &str, value: &Value) -> Option<String> {
128    let bad = || Some(format!("{key}: expected {{angle, stops: [{{color, offset 0..1}}, …]}} with two or more stops"));
129    let Some(g) = value.as_object() else { return bad() };
130    if !g.get("angle").is_some_and(finite_number) {
131        return bad();
132    }
133    let Some(stops) = g.get("stops").and_then(Value::as_array).filter(|s| s.len() >= 2) else { return bad() };
134    for stop in stops {
135        let Some(stop) = stop.as_object() else { return bad() };
136        // Round 4: a node colour is a `$color.*` token, never a literal.
137        let color_ok = stop.get("color").is_some_and(|c| token_ok(c, &["color"]));
138        if !color_ok {
139            return bad();
140        }
141        if !stop.get("offset").and_then(Value::as_f64).is_some_and(|o| (0.0..=1.0).contains(&o)) {
142            return bad();
143        }
144    }
145    if g.keys().any(|k| k != "angle" && k != "stops") {
146        return bad();
147    }
148    None
149}
150
151fn token_ok(value: &Value, groups: &[&str]) -> bool {
152    parse_token_ref(value).is_some_and(|(group, _)| groups.contains(&group.as_str())) && is_known_token(value)
153}
154
155fn finite_number(value: &Value) -> bool {
156    value.as_f64().is_some_and(f64::is_finite)
157}
158
159fn js_join(values: &[Value]) -> String {
160    values.iter().map(crate::json::to_js_string).collect::<Vec<_>>().join("|")
161}
162
163fn value_error(key: &str, spec: &KeySpec, value: &Value) -> Option<String> {
164    if let Some(values) = &spec.enum_ {
165        return if values.iter().any(|v| crate::json::strict_eq(v, value)) {
166            None
167        } else {
168            Some(format!("{key}: expected one of {}", js_join(values)))
169        };
170    }
171    let s = value.as_str();
172    match spec.type_.as_deref() {
173        Some("number") => {
174            if finite_number(value) || token_ok(value, NUMERIC_TOKEN_GROUPS) {
175                None
176            } else {
177                Some(format!("{key}: expected a number or a numeric token"))
178            }
179        }
180        Some("length") => {
181            if finite_number(value) || s == Some("auto") || s.is_some_and(is_length) || token_ok(value, NUMERIC_TOKEN_GROUPS) {
182                None
183            } else {
184                Some(format!("{key}: expected px, \"N%\", \"auto\" or a numeric token"))
185            }
186        }
187        // Round 4 (VAPP-103): a node colour is a `$color.*` TOKEN, never a
188        // literal, so the theme's light and dark modes both apply (a theme's
189        // recipes keep literal hex: theme.rs checks those).
190        Some("color") => {
191            if token_ok(value, &["color"]) {
192                None
193            } else {
194                Some(format!("{key}: expected $color.<name>"))
195            }
196        }
197        Some("gradient") => gradient_error(key, value),
198        Some("transform") => {
199            if s.is_some_and(is_transform) {
200                None
201            } else {
202                Some(format!("{key}: expected translate(Xpx, Ypx), scale(N) and/or rotate(Ndeg)"))
203            }
204        }
205        Some("token") => {
206            let group = spec.group.as_deref().unwrap_or("");
207            if token_ok(value, &[group]) {
208                None
209            } else {
210                Some(format!("{key}: expected ${}.<name>", spec.group.as_deref().unwrap_or("undefined")))
211            }
212        }
213        Some("ratio") => {
214            if value.as_f64().is_some_and(|v| v > 0.0) || s.is_some_and(is_ratio) {
215                None
216            } else {
217                Some(format!("{key}: expected a number or \"w/h\""))
218            }
219        }
220        Some("tracks") | Some("string") => {
221            if s.is_some() {
222                None
223            } else {
224                Some(format!("{key}: expected a string"))
225            }
226        }
227        Some("areas") => {
228            if value.as_array().is_some_and(|rows| rows.iter().all(Value::is_string)) {
229                None
230            } else {
231                Some(format!("{key}: expected an array of row strings"))
232            }
233        }
234        _ => Some(format!("{key}: unknown spec")),
235    }
236}
237
238/// Every rule the client resolver relies on: whitelisted keys, well-typed
239/// values, conditions one level deep, `direction` on the root only
240/// (`root == Some(false)` flags it; `None` = unknown, allowed).
241pub fn validate_style(style: &Value, path: &str, root: Option<bool>) -> Vec<StyleIssue> {
242    let Some(obj) = style.as_object() else {
243        return vec![StyleIssue { path: path.to_string(), message: "expected an object".into() }];
244    };
245    let mut issues = Vec::new();
246    walk(obj, path, false, root, &mut issues);
247    issues
248}
249
250fn walk(obj: &serde_json::Map<String, Value>, at: &str, nested: bool, root: Option<bool>, issues: &mut Vec<StyleIssue>) {
251    let specs = &*STYLE_SPECS;
252    for (key, value) in obj {
253        let here = format!("{at}.{key}");
254        if is_condition_key(key) {
255            if let Some(name) = key.find("$breakpoint.").map(|i| &key[i + "$breakpoint.".len()..]) {
256                let name: String = name.chars().take_while(char::is_ascii_alphanumeric).collect();
257                let known = crate::macros::BREAKPOINTS.contains(&name);
258                if !known {
259                    issues.push(StyleIssue { path: here.clone(), message: format!("unknown breakpoint $breakpoint.{name}; known: {}", crate::macros::BREAKPOINTS.join("|")) });
260                }
261            }
262            if nested {
263                issues.push(StyleIssue { path: here, message: "conditions do not nest".into() });
264            } else if let Some(inner) = value.as_object() {
265                walk(inner, &here, true, root, issues);
266            } else {
267                issues.push(StyleIssue { path: here, message: "must be an object".into() });
268            }
269            continue;
270        }
271        let Some(spec) = specs.get(key) else {
272            let message = if key.starts_with("@media") || key.starts_with(':') { "not a supported condition" } else { "not in the Box style whitelist" };
273            issues.push(StyleIssue { path: here, message: message.into() });
274            continue;
275        };
276        if spec.root_only == Some(true) && root == Some(false) {
277            issues.push(StyleIssue { path: here.clone(), message: "allowed on the root only".into() });
278        }
279        if let Some(error) = value_error(key, spec, value) {
280            issues.push(StyleIssue { path: here, message: error });
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serde_json::json;
289
290    fn v(style: Value) -> Vec<StyleIssue> {
291        validate_style(&style, "style", None)
292    }
293
294    #[test]
295    fn box_style_whitelist_the_spikes_v1_whitelist_plus_the_logical_inline_properties() {
296        let spike = [
297            "display", "flexDirection", "flexWrap", "justifyContent", "alignItems", "alignContent", "alignSelf", "justifySelf",
298            "flexGrow", "flexShrink", "flexBasis", "gap", "rowGap", "columnGap", "direction", "overflow", "width", "height",
299            "minWidth", "minHeight", "maxWidth", "maxHeight", "aspectRatio", "position", "top", "right", "bottom", "left", "inset",
300            "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "paddingHorizontal", "paddingVertical",
301            "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical",
302            "gridTemplateColumns", "gridTemplateRows", "gridTemplateAreas", "gridArea", "gridColumn", "gridRow",
303            "backgroundColor", "color", "borderWidth", "borderColor", "borderRadius", "opacity", "boxShadow", "fontSize",
304            "fontWeight", "lineHeight", "textAlign",
305        ];
306        for key in spike {
307            assert!(STYLE_SPECS.contains_key(key), "{key}");
308        }
309        for key in ["insetInlineStart", "insetInlineEnd", "paddingInlineStart", "paddingInlineEnd", "marginInlineStart", "marginInlineEnd"] {
310            assert!(STYLE_SPECS.contains_key(key), "{key}");
311        }
312        for key in ["zIndex", "order", "gridAutoFlow", "justifyItems", "borderTopColor"] {
313            assert!(!STYLE_SPECS.contains_key(key), "{key}");
314        }
315        for key in ["transition", "transitionEasing", "transform", "backgroundGradient", "borderTopWidth", "borderTopLeftRadius", "overflowX", "insetBlockStart", "visibility", "pointerEvents", "userSelect", "cursor", "letterSpacing", "textDecoration", "textTransform", "fontStyle", "borderStyle"] {
316            assert!(STYLE_SPECS.contains_key(key), "{key}");
317        }
318        assert_eq!(STYLE_SPECS["borderWidth"].layout_effect, Some(true));
319        let keys: Vec<&str> = STYLE_SPECS.keys().map(String::as_str).collect();
320        assert_eq!(keys, g::STYLE_KEYS);
321        assert!(g::STYLE_MEDIA_PATTERN.starts_with(r"^@media \((min-width|max-width|min-height|max-height): "));
322        assert_eq!(g::STYLE_LAYOUT_EFFECT_KEYS, ["borderWidth", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"]);
323    }
324
325    #[test]
326    fn box_style_whitelist_values_px_percent_auto_tokens_colours() {
327        assert_eq!(
328            v(json!({"width": 12, "height": "50%", "minWidth": "auto", "gap": "$spacing.md", "backgroundColor": "$color.card", "color": "$color.primary", "borderRadius": "$radius.lg", "boxShadow": "$shadow.sm", "opacity": "$opacity.secondary", "aspectRatio": "16/9", "gridTemplateAreas": ["a b"], "fontWeight": 600})),
329            vec![]
330        );
331        assert_eq!(
332            v(json!({"width": "12em"})),
333            vec![StyleIssue { path: "style.width".into(), message: "width: expected px, \"N%\", \"auto\" or a numeric token".into() }]
334        );
335        assert_eq!(v(json!({"color": "$spacing.md"}))[0].message, "color: expected $color.<name>");
336        assert_eq!(v(json!({"gap": "$spacing.huge"})).len(), 1);
337        assert!(v(json!({"display": "inline"}))[0].message.contains("expected one of"));
338        assert_eq!(v(json!({"zIndex": 2})), vec![StyleIssue { path: "style.zIndex".into(), message: "not in the Box style whitelist".into() }]);
339    }
340
341    #[test]
342    fn box_style_whitelist_conditions_surface_media_and_pressed_one_level_deep_direction_on_any_node() {
343        assert_eq!(v(json!({"@media (min-width: 600px)": {"gap": 8}, ":pressed": {"opacity": 0.6}})), vec![]);
344        assert_eq!(v(json!({"@media (min-width: 600px)": {":pressed": {"opacity": 0.6}}}))[0].message, "conditions do not nest");
345        assert_eq!(v(json!({":hover": {"opacity": 1}, ":focus-visible": {"borderColor": "$color.ring"}})), vec![]);
346        assert_eq!(v(json!({":focus": {"opacity": 1}}))[0].message, "not a supported condition");
347        assert_eq!(v(json!({"@media (min-width: 600)": {"gap": 1}}))[0].message, "not a supported condition");
348        assert_eq!(v(json!({"@media (min-width: $breakpoint.huge)": {"gap": 1}}))[0].message, "unknown breakpoint $breakpoint.huge; known: sm|md|lg|xl");
349        assert_eq!(v(json!({"@media (max-width: $breakpoint.md)": {"display": "none"}, "@media (orientation: portrait)": {"gap": 2}, "@media (hover: hover)": {"cursor": "pointer"}})), vec![]);
350        // Round 2: `direction` is valid on any node (style.json drops `rootOnly`).
351        assert_eq!(validate_style(&json!({"direction": "rtl"}), "style", Some(false)), vec![]);
352        assert_eq!(validate_style(&json!({"direction": "rtl"}), "style", Some(true)), vec![]);
353    }
354
355    #[test]
356    fn the_hand_written_patterns() {
357        assert!(is_length("-12.5px") && is_length("50%") && !is_length("12") && !is_length("1.px") && !is_length("px"));
358        assert!(is_hex_color("#fff") && is_hex_color("#ffff") && is_hex_color("#ff00ff80") && !is_hex_color("#fffff") && !is_hex_color("fff"));
359        assert!(is_ratio("16/9") && is_ratio("1.5/1") && !is_ratio("16/") && !is_ratio("-1/2"));
360        assert!(is_media_key("@media (min-width: 600px)") && is_media_key("@media (min-width: 37.5px)") && !is_media_key("@media (min-width: 600)"));
361        assert!(is_condition_key(":hover") && is_condition_key(":pressed") && !is_condition_key(":focus"));
362        assert!(is_transform("translate(4px, -2px) scale(1.5) rotate(90deg)") && is_transform("scale(1)"));
363        assert!(!is_transform("scale(1) ") && !is_transform("scale(1)  rotate(1deg)") && !is_transform("translate(4, 2)") && !is_transform("skew(1deg)") && !is_transform(""));
364    }
365
366    #[test]
367    fn round_1_value_types() {
368        assert_eq!(v(json!({"transition": "$motion.fast", "transitionEasing": "$ease.standard", "transform": "rotate(90deg)", "backgroundGradient": {"angle": 90, "stops": [{"color": "$color.card", "offset": 0}, {"color": "$color.primary", "offset": 1}]}, "borderTopWidth": 2, "borderStyle": "dashed", "borderTopLeftRadius": 8, "letterSpacing": 1, "visibility": "hidden", "fontWeight": 300})), vec![]);
369        assert!(v(json!({"transition": 200}))[0].message.contains("$motion"));
370        assert!(v(json!({"backgroundGradient": {"angle": 90, "stops": [{"color": "#fff", "offset": 0}]}}))[0].message.contains("two or more stops"));
371        // Round 4: literal colours are refused in a node's style.
372        assert_eq!(v(json!({"backgroundColor": "#ff0000"}))[0].message, "backgroundColor: expected $color.<name>");
373        assert_eq!(v(json!({"backgroundGradient": {"angle": 90, "stops": [{"color": "$color.primary", "offset": 0}, {"color": "#00000000", "offset": 1}]}})).len(), 1);
374        assert!(v(json!({"transform": "translate(1px,2px)"}))[0].message.contains("translate"));
375        assert!(v(json!({"width": {"path": "/w"}}))[0].message.contains("expected px"), "authors cannot write dynamic style values");
376    }
377
378    #[test]
379    fn round_2_style_keys_sticky_backdrop_blur_token_only_animation_and_its_duration_token() {
380        assert_eq!(v(json!({"position": "sticky", "top": 0, "backdropBlur": "$blur.md", "animation": "pulse", "animationDuration": "$motion.slow"})), vec![]);
381        assert_eq!(v(json!({"backdropBlur": 8})).len(), 1);
382        assert_eq!(v(json!({"animation": "wobble"})).len(), 1);
383        assert_eq!(v(json!({"animationDuration": 300})).len(), 1);
384    }
385}