Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/rustmotion-cli/src/commands/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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##"<rustmotion width="100" height="100"><scene duration="2"><h1 anim="not-a-preset">Hi</h1></scene></rustmotion>"##;
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)]
Expand Down
178 changes: 155 additions & 23 deletions crates/rustmotion-html/src/element.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Value> {
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<Option<Value>, 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<Value> {
let tag = tag_name(handle)?;
pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, 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<Value> {
pub(crate) fn children_to_values(handle: &Handle) -> Result<Vec<Value>, 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);
}
}
Expand All @@ -116,7 +127,7 @@ pub(crate) fn children_to_values(handle: &Handle) -> Vec<Value> {
_ => {}
}
}
out
Ok(out)
}

#[cfg(test)]
Expand All @@ -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,
Expand Down Expand Up @@ -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#"<h1 anim='[{"name":"fade_in_up","delay":0.3}]'>Hi</h1>"#);
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#"<p anim='{"name":"pulse"}'>Hi</p>"#);
assert_eq!(v["style"]["animation"], json!([{ "name": "pulse" }]));
}

#[test]
fn anim_dsl_single_effect() {
let v = map_first(r#"<h1 anim="fade-in-up delay:0.3 duration:0.8">Hi</h1>"#);
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#"<h1 anim="fade-in-up delay:0.3 duration:0.8; pulse delay:2 loop:true">Hi</h1>"#,
);
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#"<h1 anim="slide-in-left my-key:1">Hi</h1>"#);
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#"<h1 anim="fade_in_up delay:0.3">Hi</h1>"#);
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#"<h1 anim="fade-in delay0.3">Hi</h1>"#);
assert!(
matches!(e, crate::HtmlError::InvalidAnimDsl(_)),
"expected InvalidAnimDsl, got: {e:?}"
);
}

#[test]
fn anim_empty_is_error() {
let e = map_first_err(r#"<h1 anim="">Hi</h1>"#);
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#"<h1 anim="fade-in; ; pulse">Hi</h1>"#);
assert!(
matches!(e, crate::HtmlError::InvalidAnimDsl(_)),
"expected InvalidAnimDsl, got: {e:?}"
);
}

#[test]
fn anim_invalid_json_is_error() {
let e = map_first_err(r#"<h1 anim="[not valid json]">Hi</h1>"#);
assert!(
matches!(e, crate::HtmlError::InvalidAnimJson(_)),
"expected InvalidAnimJson, got: {e:?}"
);
}

#[test]
fn anim_with_existing_style_keeps_other_props() {
let v = map_first(r##"<h1 style="font-size:96; color:#fff" anim="fade-in">Hi</h1>"##);
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#"<rm-counter from="0" to="10" anim="fade-in"></rm-counter>"#);
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));
}
}
34 changes: 34 additions & 0 deletions crates/rustmotion-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<font> requires both family and path (or src) attributes")]
Expand Down Expand Up @@ -377,6 +381,36 @@ mod lib_tests {
assert!(crate::html_to_scenario_value("<div>no root</div>").is_err());
}

// --- anim round-trip (studio) ---

#[test]
fn set_inline_style_preserves_anim_attribute() {
let html = r##"<rustmotion width="100" height="100"><scene duration="2"><h1 anim="fade-in-up delay:0.3" style="font-size:96">Hi</h1></scene></rustmotion>"##;
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##"<rustmotion width="100" height="100"><scene duration="2"><h1 anim="pulse loop:true">Hi</h1></scene></rustmotion>"##;
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]
Expand Down
2 changes: 1 addition & 1 deletion crates/rustmotion-html/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
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))
}

Expand Down
Loading
Loading