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
6 changes: 5 additions & 1 deletion crates/rustmotion-html/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use markup5ever_rcdom::{Handle, NodeData};
use serde_json::{Map, Value};

use crate::style::{coerce_value, parse_anim_attr, parse_inline_style};
use crate::{element_attrs, tag_name, HtmlError};
use crate::{check_known_attrs, element_attrs, tag_name, HtmlError};

const KNOWN_NATIVE_ATTRS: &[&str] = &["style", "anim"];

enum TagKind {
Container,
Expand Down Expand Up @@ -90,6 +92,7 @@ pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, HtmlErr
suggestion: suggestion.to_string(),
}),
TagKind::Text => {
check_known_attrs(&tag, &attrs, KNOWN_NATIVE_ATTRS)?;
let mut obj = Map::new();
obj.insert("type".into(), Value::from("text"));
obj.insert("content".into(), Value::from(inner_text(handle)));
Expand All @@ -99,6 +102,7 @@ pub(crate) fn element_to_value(handle: &Handle) -> Result<Option<Value>, HtmlErr
Ok(Some(Value::Object(obj)))
}
TagKind::Container => {
check_known_attrs(&tag, &attrs, KNOWN_NATIVE_ATTRS)?;
let mut obj = Map::new();
obj.insert("type".into(), Value::from("div"));
if let Some(style) = style_object(&attrs)? {
Expand Down
115 changes: 115 additions & 0 deletions crates/rustmotion-html/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ pub enum HtmlError {
"<scene> found nested inside <{parent}> — <scene> elements must be direct children of <rustmotion> (only <font> is recursed into)"
)]
NestedScene { parent: String },
/// Emitted when an element carries an attribute the transpiler never
/// reads. `<rustmotion>`, `<scene>`, and native container/text tags only
/// ever consume a fixed, small set of attribute names — anything else
/// used to vanish with no trace, invisible to `--strict-attrs` because
/// it never reached the emitted JSON in the first place.
#[error("<{element}> has unsupported attribute(s): {detail} — these are silently ignored today; fix the typo, drop them, or use the attribute the dialect actually reads")]
UnknownAttributes { element: String, detail: String },
/// Emitted for a `<scene>`'s `world-position` attribute that is neither
/// `"x,y"` nor a JSON `{"x":..,"y":..}` object.
#[error("world-position=\"{0}\" is not \"x,y\" or a JSON object {{\"x\":..,\"y\":..}}")]
InvalidWorldPosition(String),
/// Emitted for a `<scene>`'s `animated-background` attribute when its
/// value starts with `{`/`[` but fails to parse as JSON.
#[error("animated-background attribute contains invalid JSON: {0}")]
InvalidAnimatedBackgroundJson(String),
/// Emitted for a `style="..."` declaration whose value has more than one
/// top-level (paren-aware) token and isn't one of the shorthands the
/// transpiler knows how to expand (`padding`/`margin`/`border-radius`'s
/// 1-4 value box form, `grid-template-columns`/`-rows`'s track list with
/// `repeat()`/`minmax()`). Every other multi-token value used to become
/// an opaque string the core length parser cannot read, silently
/// resolving to `0px`.
#[error("style property '{prop}' has an unsupported multi-token value '{value}' — supported multi-token forms are the padding/margin/border-radius box shorthand and grid-template-columns/-rows track lists with repeat()/minmax(); rewrite as a single value")]
UnsupportedStyleShorthand { prop: String, value: String },
/// Emitted when a `<script>`/`<img>`/`<svg>`/`<rm-*>`/container element
/// is found nested inside an inline text element (`p`/`span`/`h1..h6`/
/// `strong`/`em`/`label`). Those flatten their whole subtree to a plain
/// string — a nested element with real content (a component, a shape, a
/// child container) has nowhere to go and used to either bleed its raw
/// source into the string or vanish outright.
#[error("<{tag}> cannot appear inside an inline text element (p/span/h1..h6/strong/em/label) — those flatten their content to a plain string, so <{tag}>'s own content would be silently lost; move it outside as a sibling, or wrap the text in a <div>/<rm-*> container instead")]
TextContentUnsupportedChild { tag: String },
/// Emitted when the HTML serializer itself fails (I/O error into an
/// in-memory buffer, or non-UTF-8 output) inside the studio write-back
/// path. The write-back functions refuse (return `None`) rather than
/// hand the caller a partial or empty buffer to write to disk.
#[error("failed to serialize the rewritten HTML: {0}")]
SerializeFailed(String),
}

/// Transpile an HTML-dialect document into the scenario `serde_json::Value` that
Expand All @@ -108,6 +146,12 @@ pub fn html_to_scenario_value(html: &str) -> Result<Value, HtmlError> {
let attrs = element_attrs(&root);
let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());

check_known_attrs(
"rustmotion",
&attrs,
&["width", "height", "fps", "background", "codec", "crf"],
)?;

let width = get("width").ok_or(HtmlError::MissingDimensions)?;
let height = get("height").ok_or(HtmlError::MissingDimensions)?;

Expand All @@ -120,6 +164,12 @@ pub fn html_to_scenario_value(html: &str) -> Result<Value, HtmlError> {
if let Some(bg) = get("background") {
video.insert("background".into(), parse_background_attr(&bg)?);
}
if let Some(codec) = get("codec") {
video.insert("codec".into(), style::coerce_value(&codec));
}
if let Some(crf) = get("crf") {
video.insert("crf".into(), style::coerce_value(&crf));
}

let mut scenes = Vec::new();
let mut fonts = Vec::new();
Expand Down Expand Up @@ -263,6 +313,71 @@ pub(crate) fn element_attrs(handle: &Handle) -> Vec<(String, String)> {
}
}

/// `class`/`id`/`data-*` are accepted anywhere and never reach the emitted
/// scenario JSON — deliberately inert, not a signal of an unread attribute.
fn is_inert_attr(name: &str) -> bool {
name == "class" || name == "id" || name.starts_with("data-")
}

/// Fail on any attribute of `element` outside `known` (plus the always-inert
/// `class`/`id`/`data-*`), naming every offender in one error with a
/// did-you-mean suggestion against `known`. This is what closes the gap
/// `check_component_attrs` (`rustmotion`'s `--strict-attrs`) cannot: that
/// check only sees attributes the transpiler already forwarded into the
/// scenario JSON, so an attribute dropped here was invisible to it.
pub(crate) fn check_known_attrs(
element: &str,
attrs: &[(String, String)],
known: &[&str],
) -> Result<(), HtmlError> {
let unknown: Vec<&str> = attrs
.iter()
.map(|(k, _)| k.as_str())
.filter(|k| !known.contains(k) && !is_inert_attr(k))
.collect();
if unknown.is_empty() {
return Ok(());
}
let detail = unknown
.iter()
.map(|name| match suggest(name, known) {
Some(k) => format!("'{name}' (did you mean '{k}'?)"),
None => format!("'{name}'"),
})
.collect::<Vec<_>>()
.join(", ");
Err(HtmlError::UnknownAttributes {
element: element.to_string(),
detail,
})
}

/// The closest entry in `known` to `name` (Levenshtein distance <= 2), or
/// `None` when nothing is close enough to be worth suggesting.
fn suggest<'a>(name: &str, known: &[&'a str]) -> Option<&'a str> {
known
.iter()
.map(|k| (levenshtein(name, k), *k))
.min_by_key(|(distance, _)| *distance)
.filter(|(distance, _)| *distance <= 2)
.map(|(_, k)| k)
}

fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur.push((prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1));
}
prev = cur;
}
prev[b.len()]
}

/// Depth-first: the first descendant element with the given tag name.
pub(crate) fn find_element(handle: &Handle, tag: &str) -> Option<Handle> {
for child in handle.children.borrow().iter() {
Expand Down
72 changes: 71 additions & 1 deletion crates/rustmotion-html/src/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,75 @@ use crate::element::children_to_values;
use crate::element_attrs;
use crate::parse_background_attr;
use crate::style::coerce_value;
use crate::HtmlError;
use crate::{check_known_attrs, HtmlError};

const KNOWN_SCENE_ATTRS: &[&str] = &[
"duration",
"align",
"justify",
"direction",
"gap",
"padding",
"background",
"effects",
"transition",
"transition-duration",
"transition-easing",
"freeze_at",
"world-position",
"animated-background",
];

/// Parse an `effects` attribute value: must be a JSON array.
fn parse_effects_attr(raw: &str) -> Result<Value, HtmlError> {
let trimmed = raw.trim();
serde_json::from_str(trimmed).map_err(|e| HtmlError::InvalidEffectsJson(e.to_string()))
}

/// Parse a `world-position` attribute: either `"x,y"` (the idiomatic HTML
/// form, mirroring `font`'s `weights="400,700"` CSV convention) or a JSON
/// `{"x":..,"y":..}` object, matching `WorldPosition`'s wire shape.
fn parse_world_position_attr(raw: &str) -> Result<Value, HtmlError> {
let trimmed = raw.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed)
.map_err(|_| HtmlError::InvalidWorldPosition(raw.to_string()));
}
let parts: Vec<&str> = trimmed.split(',').map(str::trim).collect();
let [x, y] = parts.as_slice() else {
return Err(HtmlError::InvalidWorldPosition(raw.to_string()));
};
let x: f32 = x
.parse()
.map_err(|_| HtmlError::InvalidWorldPosition(raw.to_string()))?;
let y: f32 = y
.parse()
.map_err(|_| HtmlError::InvalidWorldPosition(raw.to_string()))?;
Ok(serde_json::json!({ "x": x, "y": y }))
}

/// Parse an `animated-background` attribute: JSON object/array (matching
/// `scene["animated-background"]`'s `AnimatedBackground` shape, e.g.
/// `{"preset":"halo","halo":{...},"speed":0}`) if it starts with `{`/`[`,
/// otherwise a bare string (left for the typed scenario loader to reject —
/// no preset is nameable without its config, so this never has a legitimate
/// bare-string form, but the reachable-attribute goal is what matters here:
/// the value now always reaches the JSON instead of vanishing before it).
fn parse_animated_background_attr(raw: &str) -> Result<Value, HtmlError> {
let trimmed = raw.trim();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
serde_json::from_str(trimmed)
.map_err(|e| HtmlError::InvalidAnimatedBackgroundJson(e.to_string()))
} else {
Ok(Value::from(raw))
}
}

/// Map a `<scene>` element to a scene JSON object. Defaults to a centered flex
/// layout (overridable via `align`/`justify`/`direction`/`gap`/`padding` attrs).
pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
let attrs = element_attrs(handle);
check_known_attrs("scene", &attrs, KNOWN_SCENE_ATTRS)?;
let get = |k: &str| attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());

let duration = get("duration").ok_or(HtmlError::MissingDuration)?;
Expand Down Expand Up @@ -77,6 +134,19 @@ pub(crate) fn scene_to_value(handle: &Handle) -> Result<Value, HtmlError> {
return Err(HtmlError::TransitionParamsWithoutTransition);
}

if let Some(fa) = get("freeze_at") {
obj.insert("freeze_at".into(), coerce_value(&fa));
}
if let Some(wp) = get("world-position") {
obj.insert("world-position".into(), parse_world_position_attr(&wp)?);
}
if let Some(ab) = get("animated-background") {
obj.insert(
"animated-background".into(),
parse_animated_background_attr(&ab)?,
);
}

obj.insert("children".into(), Value::Array(children_to_values(handle)?));
Ok(Value::Object(obj))
}
Expand Down
97 changes: 97 additions & 0 deletions crates/rustmotion-html/tests/audit_ws_f.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Regression tests for workstream F's 4 confirmed audit findings on the
//! HTML dialect transpiler: an attribute outside a hardcoded allowlist
//! vanishing with no error, a CSS shorthand transpiling to a string
//! the core length parser cannot read and silently resolving to 0px,
//! `<script>`/`<style>` source getting painted as text while `<img>`/`<svg>`
//! children vanish inside inline text elements, and the studio's
//! HTML write-back deleting everything outside `<rustmotion>` in the
//! author's file.

use rustmotion_html::{html_to_scenario_value, HtmlError};
use serde_json::json;

// ---------------------------------------------------------------------------
// <scene> and <rustmotion> read a hardcoded attribute allowlist;
// every other attribute is dropped with no error.
// ---------------------------------------------------------------------------

#[test]
fn scene_freeze_at_world_position_and_animated_background_are_reachable() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2" freeze_at="1" world-position="10,20" animated-background='{"preset":"halo","halo":{"zones":[{"color":"#7c3aed","x":0.5,"y":0.5,"radius":0.4}]},"speed":0}'><h1>Hi</h1></scene></rustmotion>"##;
let v = html_to_scenario_value(html).expect("known scene attributes must transpile");
assert_eq!(v["scenes"][0]["freeze_at"], json!(1));
assert_eq!(v["scenes"][0]["world-position"]["x"], json!(10.0));
assert_eq!(v["scenes"][0]["world-position"]["y"], json!(20.0));
assert_eq!(
v["scenes"][0]["animated-background"]["preset"],
json!("halo")
);
}

#[test]
fn scene_unknown_attribute_is_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2" bogus-attr="x"><h1>Hi</h1></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("an unknown <scene> attribute must be refused, not silently dropped");
match err {
HtmlError::UnknownAttributes { element, detail } => {
assert_eq!(element, "scene");
assert!(detail.contains("bogus-attr"), "got: {detail}");
}
other => panic!("expected UnknownAttributes, got: {other:?}"),
}
}

#[test]
fn root_codec_is_reachable_and_unknown_root_attribute_is_refused() {
let ok = r##"<rustmotion width="1920" height="1080" codec="prores"><scene duration="2"><h1>Hi</h1></scene></rustmotion>"##;
let v = html_to_scenario_value(ok).expect("codec must transpile, not be dropped");
assert_eq!(v["video"]["codec"], json!("prores"));

let bad = r##"<rustmotion width="1920" height="1080" codec="prores" durationn="5"><scene duration="2"><h1>Hi</h1></scene></rustmotion>"##;
let err = html_to_scenario_value(bad)
.expect_err("an unknown <rustmotion> attribute must be refused, not silently dropped");
match err {
HtmlError::UnknownAttributes { element, detail } => {
assert_eq!(element, "rustmotion");
assert!(detail.contains("durationn"), "got: {detail}");
}
other => panic!("expected UnknownAttributes, got: {other:?}"),
}
}

#[test]
fn container_unknown_attributes_are_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div gap="32" width="400" id="x"></div></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("unknown <div> attributes must be refused, not silently dropped");
match err {
HtmlError::UnknownAttributes { element, detail } => {
assert_eq!(element, "div");
assert!(detail.contains("gap"), "got: {detail}");
assert!(detail.contains("width"), "got: {detail}");
assert!(!detail.contains("'id'"), "id must stay inert: {detail}");
}
other => panic!("expected UnknownAttributes, got: {other:?}"),
}
}

#[test]
fn text_tag_unknown_attribute_is_refused_not_dropped() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><p foo="bar">Hi</p></scene></rustmotion>"##;
let err = html_to_scenario_value(html)
.expect_err("unknown <p> attributes must be refused, not silently dropped");
match err {
HtmlError::UnknownAttributes { element, detail } => {
assert_eq!(element, "p");
assert!(detail.contains("foo"), "got: {detail}");
}
other => panic!("expected UnknownAttributes, got: {other:?}"),
}
}

#[test]
fn inert_attributes_stay_inert_on_container_and_text() {
let html = r##"<rustmotion width="1920" height="1080"><scene duration="2"><div class="wrapper" id="hero" data-testid="x"><p class="lead" data-x="1">Hi</p></div></scene></rustmotion>"##;
html_to_scenario_value(html).expect("class/id/data-* must remain inert, not flagged");
}
Loading