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
147 changes: 145 additions & 2 deletions crates/rustmotion-html/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ use crate::{element_attrs, tag_name, HtmlError};
enum TagKind {
Container,
Text,
/// Tags that never visually render in real HTML either (`<script>`,
/// `<title>`, `<noscript>`, `<template>`, `<head>`) — skipped to match
/// that expectation, rather than painted as a stray `text` component.
/// `<style>` is deliberately NOT in this bucket: see `element_to_value`.
Ignored,
/// A native HTML tag with no representation beyond an empty `div`: its
/// real payload (`src`, nested shape markup, …) would be silently
/// dropped by the generic `Container` fallback. Refused instead, naming
/// the dialect's `rm-*` custom-element equivalent.
UnsupportedNative(&'static str),
Custom(String),
}

Expand All @@ -16,6 +26,10 @@ fn tag_kind(tag: &str) -> TagKind {
"p" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "strong" | "em" | "label" => {
TagKind::Text
}
"script" | "title" | "noscript" | "template" | "head" => TagKind::Ignored,
"img" => TagKind::UnsupportedNative("rm-image"),
"video" => TagKind::UnsupportedNative("rm-video"),
"svg" => TagKind::UnsupportedNative("rm-svg"),
t if t.starts_with("rm-") => TagKind::Custom(t["rm-".len()..].to_string()),
_ => TagKind::Container,
}
Expand Down Expand Up @@ -62,8 +76,19 @@ pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, HtmlErr
let Some(tag) = tag_name(handle) else {
return Ok(None);
};
// `<style>` has real, expected visual effect in HTML (unlike the tags in
// `TagKind::Ignored`), so silently dropping it would defeat the author's
// intent without a trace — refused instead. See `HtmlError::StyleElementUnsupported`.
if tag == "style" {
return Err(HtmlError::StyleElementUnsupported);
}
let attrs = element_attrs(handle);
match tag_kind(&tag) {
TagKind::Ignored => Ok(None),
TagKind::UnsupportedNative(suggestion) => Err(HtmlError::UnsupportedNativeElement {
tag,
suggestion: suggestion.to_string(),
}),
TagKind::Text => {
let mut obj = Map::new();
obj.insert("type".into(), Value::from("text"));
Expand All @@ -89,10 +114,24 @@ pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, HtmlErr
let mut obj = Map::new();
obj.insert("type".into(), Value::from(type_name));
for (k, v) in &attrs {
if k == "style" || k == "class" || k == "anim" || v.is_empty() {
if k == "style" || k == "class" || k == "anim" {
continue;
}
obj.insert(k.clone(), coerce_value(v));
// A bare HTML boolean attribute (`<rm-codeblock diff>`) is
// indistinguishable, at the DOM level, from an explicit empty
// value (`diff=""`) — html5ever normalizes both to the same
// empty attribute value. Per HTML's own boolean-attribute
// convention (`<video controls>`, `<input disabled>`), treat
// an empty value as `true` rather than silently dropping the
// attribute: for a bool schema field this is exactly the
// author's intent; for any other field type, `validate`
// reports a named type-mismatch instead of a silent no-op.
let value = if v.is_empty() {
Value::Bool(true)
} else {
coerce_value(v)
};
obj.insert(k.clone(), value);
}
if let Some(style) = style_object(&attrs)? {
obj.insert("style".into(), style);
Expand Down Expand Up @@ -327,4 +366,108 @@ mod tests {
assert_eq!(v["style"]["animation"][0]["name"], json!("fade_in"));
assert_eq!(v["from"], json!(0));
}

// --- <style>/ignored elements (constat 1) ---

#[test]
fn style_element_is_refused() {
let e = map_first_err(r#"<style>h1 { color: #0f0 }</style>"#);
assert!(
matches!(e, crate::HtmlError::StyleElementUnsupported),
"expected StyleElementUnsupported, got: {e:?}"
);
}

#[test]
fn script_element_is_skipped_not_painted() {
let v = map_first(r#"<div><script>alert(1)</script><p>real</p></div>"#);
let children = v["children"].as_array().expect("children array");
assert_eq!(
children.len(),
1,
"script content must not become a component: {v}"
);
assert_eq!(children[0]["content"], json!("real"));
}

#[test]
fn title_and_noscript_and_template_elements_are_skipped_not_painted() {
let v = map_first(
r#"<div><title>tt</title><noscript>ns</noscript><template>tpl</template><p>real</p></div>"#,
);
let children = v["children"].as_array().expect("children array");
assert_eq!(
children.len(),
1,
"title/noscript/template content must not become a component: {v}"
);
assert_eq!(children[0]["content"], json!("real"));
}

#[test]
fn tag_kind_head_is_ignored() {
// <head> content never survives as a distinct DOM node when authored
// inline (html5ever drops the wrapper per HTML5 "in body" parsing
// rules and lets its text bleed into the parent), so this can only be
// exercised at the `tag_kind` unit level, not through the full
// element_to_value/html_to_scenario_value pipeline.
assert!(matches!(tag_kind("head"), TagKind::Ignored));
}

// --- unsupported native elements (constat 3) ---

#[test]
fn img_element_is_refused_with_rm_image_suggestion() {
let e = map_first_err(r#"<img src="hero.png" width="400" height="300">"#);
match e {
crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => {
assert_eq!(tag, "img");
assert_eq!(suggestion, "rm-image");
}
other => panic!("expected UnsupportedNativeElement, got: {other:?}"),
}
}

#[test]
fn video_element_is_refused_with_rm_video_suggestion() {
let e = map_first_err(r#"<video src="clip.mp4"></video>"#);
match e {
crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => {
assert_eq!(tag, "video");
assert_eq!(suggestion, "rm-video");
}
other => panic!("expected UnsupportedNativeElement, got: {other:?}"),
}
}

#[test]
fn svg_element_is_refused_with_rm_svg_suggestion() {
let e = map_first_err(r#"<svg viewBox="0 0 10 10"><circle r="4"></circle></svg>"#);
match e {
crate::HtmlError::UnsupportedNativeElement { tag, suggestion } => {
assert_eq!(tag, "svg");
assert_eq!(suggestion, "rm-svg");
}
other => panic!("expected UnsupportedNativeElement, got: {other:?}"),
}
}

// --- boolean attributes on custom elements (constat 4) ---

#[test]
fn custom_element_bool_attribute_true_and_false() {
let v = map_first(r#"<rm-codeblock auto_scroll="false" diff="true"></rm-codeblock>"#);
assert_eq!(v["auto_scroll"], json!(false));
assert_eq!(v["diff"], json!(true));
}

#[test]
fn custom_element_bare_attribute_becomes_true() {
let v = map_first(r#"<rm-codeblock diff></rm-codeblock>"#);
assert_eq!(
v["diff"],
json!(true),
"bare boolean attribute must become true, not be dropped: {v}"
);
}
}
43 changes: 43 additions & 0 deletions crates/rustmotion-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,34 @@ pub enum HtmlError {
/// Emitted when `<font>` sets both `path`/`src` and `source` — they are mutually exclusive.
#[error("<font family=\"{family}\">: 'path'/'src' and 'source' are mutually exclusive")]
FontPathAndSourceConflict { family: String },
/// Emitted for `<style>`. Unlike `<script>`/`<title>`/`<noscript>`/`<template>`
/// (silently skipped — they never visually render in real HTML either, so
/// skipping them matches an author's own expectation), `<style>` DOES have
/// real, expected visual effect in HTML. The dialect has no CSS
/// selector/cascade engine (only inline `style="..."` attributes), so a
/// `<style>` block's rules would never take effect — refused instead of
/// silently discarded, so the author's intent isn't dropped without a trace.
#[error(
"<style> blocks are not supported by the HTML dialect (no CSS selector/cascade engine) — move these declarations onto the target elements' style=\"...\" attribute"
)]
StyleElementUnsupported,
/// Emitted for a native HTML tag whose real payload (`src`, nested shape
/// markup, …) has no representation via the generic `Container`/`div`
/// fallback — that fallback would silently render an empty box. The
/// dialect's `rm-*` custom-element mechanism is the way to express these.
#[error(
"<{tag}> is not supported by the HTML dialect and would render as an empty container — use <{suggestion} ...> instead"
)]
UnsupportedNativeElement { tag: String, suggestion: String },
/// Emitted when a `<scene>` is found nested inside an element other than
/// `<rustmotion>` itself (or `<font>`, which the transpiler recurses
/// through to work around html5ever's formatting-element reconstruction).
/// `collect_scenes_and_fonts` only walks direct children, so a nested
/// `<scene>` would otherwise vanish from the scenario without a trace.
#[error(
"<scene> found nested inside <{parent}> — <scene> elements must be direct children of <rustmotion> (only <font> is recursed into)"
)]
NestedScene { parent: String },
}

/// Transpile an HTML-dialect document into the scenario `serde_json::Value` that
Expand Down Expand Up @@ -169,6 +197,15 @@ fn font_to_value(handle: &Handle) -> Result<Value, HtmlError> {
/// element and nests subsequent siblings inside it, we recurse into `<font>`
/// children so that `<scene>` elements placed after `<font>` declarations are
/// still found at any depth.
///
/// Any other child is scanned (at any depth) for a nested `<scene>` — a
/// wrapper element (typo'd unclosed tag, deliberate `<div>` grouping, or
/// html5ever's own formatting-element error recovery on tags like `<b>`)
/// would otherwise make `<scene>` elements vanish from the scenario with no
/// trace, since this function only descends into direct children. A `<style>`
/// found at this level is refused for the same reason `element_to_value`
/// refuses it inside a scene: it has real expected visual effect that the
/// dialect cannot honor, so it must not be silently dropped either.
fn collect_scenes_and_fonts(
parent: &Handle,
scenes: &mut Vec<Value>,
Expand All @@ -182,6 +219,12 @@ fn collect_scenes_and_fonts(
// Recurse: html5ever may nest siblings inside the <font> element.
collect_scenes_and_fonts(child, scenes, fonts)?;
}
Some("style") => return Err(HtmlError::StyleElementUnsupported),
Some(other) if find_element(child, "scene").is_some() => {
return Err(HtmlError::NestedScene {
parent: other.to_string(),
});
}
_ => {}
}
}
Expand Down
24 changes: 21 additions & 3 deletions crates/rustmotion-html/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ use serde_json::{Map, Value};

use crate::HtmlError;

/// Coerce a CSS value string into JSON. A bare number or `<n>px` becomes a JSON
/// number (integral → integer, so it deserializes into `u32`/`f32` fields);
/// everything else (`%`, `auto`, `fr`, colors, keywords) stays a string.
/// Coerce a CSS value string into JSON. `true`/`false` become a JSON boolean
/// (aligned with [`coerce_dsl_value`] — without this, no `bool` schema field
/// is reachable from HTML: `auto_scroll`, `diff`, `loop`, `show_grid`,
/// `show_borders`, `pulse`, … all reject the JSON string `"true"`/`"false"`
/// that a naive coercion would otherwise produce). A bare number or `<n>px`
/// becomes a JSON number (integral → integer, so it deserializes into
/// `u32`/`f32` fields); everything else (`%`, `auto`, `fr`, colors, keywords)
/// stays a string.
pub fn coerce_value(raw: &str) -> Value {
let t = raw.trim();
if t == "true" {
return Value::Bool(true);
}
if t == "false" {
return Value::Bool(false);
}
let num = t.strip_suffix("px").unwrap_or(t).trim();
if let Ok(f) = num.parse::<f64>() {
if f.fract() == 0.0 && f.abs() < 9_007_199_254_740_992.0 {
Expand Down Expand Up @@ -167,6 +178,13 @@ mod tests {
assert_eq!(coerce_value("1fr"), json!("1fr"));
}

#[test]
fn coerce_value_true_false_become_json_booleans() {
assert_eq!(coerce_value("true"), json!(true));
assert_eq!(coerce_value("false"), json!(false));
assert_eq!(coerce_value(" true "), json!(true), "trims whitespace too");
}

#[test]
fn parses_declarations_into_style_object() {
let m = parse_inline_style("font-size:96px; color:#fff; text-align:center");
Expand Down
Loading
Loading