1use 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#[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
35pub 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
55fn decimal(s: &str) -> bool {
57 match s.split_once('.') {
58 Some((i, f)) => digits(i) && digits(f),
59 None => digits(s),
60 }
61}
62
63fn 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
70pub 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
76fn 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
83fn signed_decimal(s: &str) -> bool {
85 decimal(s.strip_prefix('-').unwrap_or(s))
86}
87
88fn 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
102pub fn is_transform(s: &str) -> bool {
105 if s.is_empty() || s.starts_with(' ') || s.ends_with(' ') {
106 return false;
107 }
108 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 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 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
238pub 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 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 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}