diff --git a/crates/rustmotion-cli/src/commands/validation.rs b/crates/rustmotion-cli/src/commands/validation.rs index 51bfc72..df8d0d1 100644 --- a/crates/rustmotion-cli/src/commands/validation.rs +++ b/crates/rustmotion-cli/src/commands/validation.rs @@ -280,6 +280,28 @@ mod html_css_error_tests { ); assert!(report.is_blocking(false), "must block rendering"); } + + #[test] + fn unknown_animation_preset_from_html_is_a_blocking_error() { + // Unknown preset names are not validated in the transpiler (no schema + // dependency there); the typed deserialization of `style.animation` + // (tagged AnimationEffect enum) must reject them here, as a blocking + // and readable error. + let html = r##"

Hi

"##; + let value = rustmotion::loader::html_to_scenario_json(html).expect("transpiles"); + let json = serde_json::to_string(&value).unwrap(); + let loaded = load(ValidationSource::Inline(&json)).expect("loads"); + let report = run_checks(&loaded, false); + assert!( + report + .schema_errors + .iter() + .any(|e| e.contains("not_a_preset")), + "expected a schema error naming the unknown preset: {:?}", + report.schema_errors + ); + assert!(report.is_blocking(false), "must block rendering"); + } } #[cfg(test)] diff --git a/crates/rustmotion-html/src/element.rs b/crates/rustmotion-html/src/element.rs index d5341a6..66db5c2 100644 --- a/crates/rustmotion-html/src/element.rs +++ b/crates/rustmotion-html/src/element.rs @@ -1,8 +1,8 @@ use markup5ever_rcdom::{Handle, NodeData}; use serde_json::{Map, Value}; -use crate::style::{coerce_value, parse_inline_style}; -use crate::{element_attrs, tag_name}; +use crate::style::{coerce_value, parse_anim_attr, parse_inline_style}; +use crate::{element_attrs, tag_name, HtmlError}; enum TagKind { Container, @@ -37,72 +37,83 @@ fn collect_text(handle: &Handle, out: &mut String) { } } -/// Pull `style="..."` from an element's attributes into a JSON style object. -fn style_object(attrs: &[(String, String)]) -> Option { - let raw = attrs.iter().find(|(k, _)| k == "style").map(|(_, v)| v)?; - let map = parse_inline_style(raw); +/// Pull `style="..."` and `anim="..."` from an element's attributes into one +/// JSON style object. `anim` (JSON or compact DSL, see +/// [`crate::style::parse_anim_attr`]) lands in `style.animation` — inline CSS +/// cannot express animation arrays, so `anim` is the only writer of that key. +fn style_object(attrs: &[(String, String)]) -> Result, HtmlError> { + let mut map = attrs + .iter() + .find(|(k, _)| k == "style") + .map(|(_, raw)| parse_inline_style(raw)) + .unwrap_or_default(); + if let Some((_, anim)) = attrs.iter().find(|(k, _)| k == "anim") { + map.insert("animation".into(), parse_anim_attr(anim)?); + } if map.is_empty() { - None + Ok(None) } else { - Some(Value::Object(map)) + Ok(Some(Value::Object(map))) } } /// Map a single element handle to its component JSON value, or `None` to skip. -pub(crate) fn element_to_value(handle: &Handle) -> Option { - let tag = tag_name(handle)?; +pub(crate) fn element_to_value(handle: &Handle) -> Result, HtmlError> { + let Some(tag) = tag_name(handle) else { + return Ok(None); + }; let attrs = element_attrs(handle); match tag_kind(&tag) { TagKind::Text => { let mut obj = Map::new(); obj.insert("type".into(), Value::from("text")); obj.insert("content".into(), Value::from(inner_text(handle))); - if let Some(style) = style_object(&attrs) { + if let Some(style) = style_object(&attrs)? { obj.insert("style".into(), style); } - Some(Value::Object(obj)) + Ok(Some(Value::Object(obj))) } TagKind::Container => { let mut obj = Map::new(); obj.insert("type".into(), Value::from("div")); - if let Some(style) = style_object(&attrs) { + if let Some(style) = style_object(&attrs)? { obj.insert("style".into(), style); } - let children = children_to_values(handle); + let children = children_to_values(handle)?; if !children.is_empty() { obj.insert("children".into(), Value::Array(children)); } - Some(Value::Object(obj)) + Ok(Some(Value::Object(obj))) } TagKind::Custom(type_name) => { let mut obj = Map::new(); obj.insert("type".into(), Value::from(type_name)); for (k, v) in &attrs { - if k == "style" || k == "class" || v.is_empty() { + if k == "style" || k == "class" || k == "anim" || v.is_empty() { continue; } obj.insert(k.clone(), coerce_value(v)); } - if let Some(style) = style_object(&attrs) { + if let Some(style) = style_object(&attrs)? { obj.insert("style".into(), style); } - let children = children_to_values(handle); + let children = children_to_values(handle)?; if !children.is_empty() { obj.insert("children".into(), Value::Array(children)); } - Some(Value::Object(obj)) + Ok(Some(Value::Object(obj))) } } } /// Map a container's children: element children via `element_to_value`, and /// non-whitespace bare text nodes into `text` components. -pub(crate) fn children_to_values(handle: &Handle) -> Vec { +pub(crate) fn children_to_values(handle: &Handle) -> Result, HtmlError> { let mut out = Vec::new(); for child in handle.children.borrow().iter() { match &child.data { NodeData::Element { .. } => { - if let Some(v) = element_to_value(child) { + if let Some(v) = element_to_value(child)? { out.push(v); } } @@ -116,7 +127,7 @@ pub(crate) fn children_to_values(handle: &Handle) -> Vec { _ => {} } } - out + Ok(out) } #[cfg(test)] @@ -127,7 +138,15 @@ mod tests { fn map_first(html: &str) -> Value { let dom = crate::parse_fragment_dom(html); let first = find_first_element(&dom.document).expect("an element"); - element_to_value(&first).expect("maps to a value") + element_to_value(&first) + .expect("no transpile error") + .expect("maps to a value") + } + + fn map_first_err(html: &str) -> crate::HtmlError { + let dom = crate::parse_fragment_dom(html); + let first = find_first_element(&dom.document).expect("an element"); + element_to_value(&first).expect_err("expected a transpile error") } // Skips the html/head/body wrappers html5ever inserts around a fragment, @@ -195,4 +214,117 @@ mod tests { assert_eq!(v["children"][0]["type"], json!("text")); assert_eq!(v["children"][0]["content"], json!("inside")); } + + // --- anim attribute --- + + #[test] + fn anim_json_array_goes_to_style_animation() { + let v = map_first(r#"

Hi

"#); + assert_eq!( + v["style"]["animation"], + json!([{ "name": "fade_in_up", "delay": 0.3 }]) + ); + } + + #[test] + fn anim_json_object_is_wrapped_in_array() { + let v = map_first(r#"

Hi

"#); + assert_eq!(v["style"]["animation"], json!([{ "name": "pulse" }])); + } + + #[test] + fn anim_dsl_single_effect() { + let v = map_first(r#"

Hi

"#); + assert_eq!( + v["style"]["animation"], + json!([{ "name": "fade_in_up", "delay": 0.3, "duration": 0.8 }]) + ); + } + + #[test] + fn anim_dsl_multi_effects_with_bool() { + let v = map_first( + r#"

Hi

"#, + ); + let anim = v["style"]["animation"].as_array().expect("array"); + assert_eq!(anim.len(), 2); + assert_eq!(anim[0]["name"], json!("fade_in_up")); + assert_eq!( + anim[1], + json!({ "name": "pulse", "delay": 2, "loop": true }) + ); + } + + #[test] + fn anim_dsl_kebab_name_and_keys_become_snake() { + let v = map_first(r#"

Hi

"#); + let effect = &v["style"]["animation"][0]; + assert_eq!(effect["name"], json!("slide_in_left")); + assert_eq!(effect["my_key"], json!(1)); + assert!( + effect.get("my-key").is_none(), + "kebab key must be converted" + ); + } + + #[test] + fn anim_dsl_snake_case_accepted_as_is() { + let v = map_first(r#"

Hi

"#); + assert_eq!(v["style"]["animation"][0]["name"], json!("fade_in_up")); + } + + #[test] + fn anim_dsl_pair_without_colon_is_error() { + let e = map_first_err(r#"

Hi

"#); + assert!( + matches!(e, crate::HtmlError::InvalidAnimDsl(_)), + "expected InvalidAnimDsl, got: {e:?}" + ); + } + + #[test] + fn anim_empty_is_error() { + let e = map_first_err(r#"

Hi

"#); + assert!( + matches!(e, crate::HtmlError::InvalidAnimDsl(_)), + "expected InvalidAnimDsl, got: {e:?}" + ); + } + + #[test] + fn anim_empty_effect_between_separators_is_error() { + let e = map_first_err(r#"

Hi

"#); + assert!( + matches!(e, crate::HtmlError::InvalidAnimDsl(_)), + "expected InvalidAnimDsl, got: {e:?}" + ); + } + + #[test] + fn anim_invalid_json_is_error() { + let e = map_first_err(r#"

Hi

"#); + assert!( + matches!(e, crate::HtmlError::InvalidAnimJson(_)), + "expected InvalidAnimJson, got: {e:?}" + ); + } + + #[test] + fn anim_with_existing_style_keeps_other_props() { + let v = map_first(r##"

Hi

"##); + assert_eq!(v["style"]["font-size"], json!(96)); + assert_eq!(v["style"]["color"], json!("#fff")); + assert_eq!(v["style"]["animation"][0]["name"], json!("fade_in")); + } + + #[test] + fn anim_on_custom_element_is_not_a_root_field() { + let v = map_first(r#""#); + assert!( + v.get("anim").is_none(), + "anim must not leak as a component field: {v}" + ); + assert_eq!(v["style"]["animation"][0]["name"], json!("fade_in")); + assert_eq!(v["from"], json!(0)); + } } diff --git a/crates/rustmotion-html/src/lib.rs b/crates/rustmotion-html/src/lib.rs index 5b6fe15..4c3bed9 100644 --- a/crates/rustmotion-html/src/lib.rs +++ b/crates/rustmotion-html/src/lib.rs @@ -24,6 +24,10 @@ pub enum HtmlError { MissingDuration, #[error("background attribute contains invalid JSON: {0}")] InvalidBackgroundJson(String), + #[error("anim attribute contains invalid JSON: {0}")] + InvalidAnimJson(String), + #[error("invalid anim DSL: {0}")] + InvalidAnimDsl(String), #[error("transition-duration and transition-easing require a transition attribute")] TransitionParamsWithoutTransition, #[error(" requires both family and path (or src) attributes")] @@ -377,6 +381,36 @@ mod lib_tests { assert!(crate::html_to_scenario_value("
no root
").is_err()); } + // --- anim round-trip (studio) --- + + #[test] + fn set_inline_style_preserves_anim_attribute() { + let html = r##"

Hi

"##; + let out = + crate::set_inline_style(html, "/scenes/0/children/0", "font-size", "120").unwrap(); + let v = crate::html_to_scenario_value(&out).unwrap(); + let child = &v["scenes"][0]["children"][0]; + assert_eq!(child["style"]["font-size"], json!(120)); + assert_eq!( + child["style"]["animation"], + json!([{ "name": "fade_in_up", "delay": 0.3 }]), + "anim attribute must survive serialize_element" + ); + } + + #[test] + fn set_text_content_preserves_anim_attribute() { + let html = r##"

Hi

"##; + let out = crate::set_text_content(html, "/scenes/0/children/0", "Bonjour").unwrap(); + let v = crate::html_to_scenario_value(&out).unwrap(); + let child = &v["scenes"][0]["children"][0]; + assert_eq!(child["content"], json!("Bonjour")); + assert_eq!( + child["style"]["animation"], + json!([{ "name": "pulse", "loop": true }]) + ); + } + // --- font tests --- #[test] diff --git a/crates/rustmotion-html/src/scene.rs b/crates/rustmotion-html/src/scene.rs index 2e9bd27..e061167 100644 --- a/crates/rustmotion-html/src/scene.rs +++ b/crates/rustmotion-html/src/scene.rs @@ -59,7 +59,7 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result { return Err(HtmlError::TransitionParamsWithoutTransition); } - obj.insert("children".into(), Value::Array(children_to_values(handle))); + obj.insert("children".into(), Value::Array(children_to_values(handle)?)); Ok(Value::Object(obj)) } diff --git a/crates/rustmotion-html/src/style.rs b/crates/rustmotion-html/src/style.rs index 184deb5..da5e63d 100644 --- a/crates/rustmotion-html/src/style.rs +++ b/crates/rustmotion-html/src/style.rs @@ -1,5 +1,7 @@ use serde_json::{Map, Value}; +use crate::HtmlError; + /// Coerce a CSS value string into JSON. A bare number or `px` becomes a JSON /// number (integral → integer, so it deserializes into `u32`/`f32` fields); /// everything else (`%`, `auto`, `fr`, colors, keywords) stays a string. @@ -43,6 +45,91 @@ pub fn parse_inline_style(decls: &str) -> Map { map } +/// Parse an `anim` attribute into the `style.animation` JSON array. +/// +/// Two forms: +/// - JSON: `[{"name":"fade_in_up","delay":0.3}]` (array, inserted as-is) or +/// `{"name":"pulse"}` (single object, wrapped in an array); +/// - compact DSL: `fade-in-up delay:0.3 duration:0.8; pulse loop:true` — +/// effects separated by `;`, each effect is a preset name (kebab-case is +/// converted to snake_case) followed by space-separated `key:value` pairs. +/// +/// Unknown preset names are not validated here (no schema dependency); the +/// typed deserialization of `style.animation` rejects them at validation time. +pub fn parse_anim_attr(raw: &str) -> Result { + let t = raw.trim(); + if t.is_empty() { + return Err(HtmlError::InvalidAnimDsl("empty anim attribute".into())); + } + if t.starts_with('[') { + return serde_json::from_str(t).map_err(|e| HtmlError::InvalidAnimJson(e.to_string())); + } + if t.starts_with('{') { + let v: Value = + serde_json::from_str(t).map_err(|e| HtmlError::InvalidAnimJson(e.to_string()))?; + return Ok(Value::Array(vec![v])); + } + + let mut effects = Vec::new(); + for effect in t.split(';') { + let effect = effect.trim(); + if effect.is_empty() { + return Err(HtmlError::InvalidAnimDsl(format!( + "empty effect in anim attribute '{raw}'" + ))); + } + let mut tokens = effect.split_whitespace(); + let name = tokens.next().expect("effect is non-empty"); + if name.contains(':') { + return Err(HtmlError::InvalidAnimDsl(format!( + "effect '{effect}' must start with a preset name, got '{name}'" + ))); + } + let mut obj = Map::new(); + obj.insert("name".into(), Value::from(kebab_to_snake(name))); + for pair in tokens { + let Some((k, v)) = pair.split_once(':') else { + return Err(HtmlError::InvalidAnimDsl(format!( + "'{pair}' is not a key:value pair (in effect '{effect}')" + ))); + }; + if k.is_empty() || v.is_empty() { + return Err(HtmlError::InvalidAnimDsl(format!( + "'{pair}' has an empty key or value (in effect '{effect}')" + ))); + } + obj.insert(kebab_to_snake(k), coerce_dsl_value(v)); + } + effects.push(Value::Object(obj)); + } + Ok(Value::Array(effects)) +} + +/// `fade-in-up` → `fade_in_up` (snake_case passes through unchanged). +fn kebab_to_snake(s: &str) -> String { + s.replace('-', "_") +} + +/// Coerce a DSL value: `true`/`false` → bool, number → JSON number +/// (integral → integer), anything else → string (e.g. easing names). +fn coerce_dsl_value(raw: &str) -> Value { + match raw { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => { + if let Ok(f) = raw.parse::() { + if f.fract() == 0.0 && f.abs() < 9_007_199_254_740_992.0 { + Value::from(f as i64) + } else { + Value::from(f) + } + } else { + Value::from(raw.to_string()) + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/examples/html/showcase.html b/examples/html/showcase.html index a068c6b..2af5bdb 100644 --- a/examples/html/showcase.html +++ b/examples/html/showcase.html @@ -3,34 +3,36 @@ - +
-

+

HTML Dialect

-

- Backgrounds · Transitions · Fonts +

+ Backgrounds · Transitions · Fonts · Animations

- +
-

+

Animated Background

-

- Inline JSON in the background attribute -

+ +

Inline JSON backgrounds

+

Parameterized transitions

+

Compact animation DSL

+
- +
-

+

Grid Dots Preset