feat(html): wire full import pipeline with root frame and defense limits
This commit is contained in:
parent
a0b202b97a
commit
97f1cf6b44
79
crates/op-html/src/e2e_tests.rs
Normal file
79
crates/op-html/src/e2e_tests.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use crate::{import_html, HtmlImportOptions};
|
||||
use jian_ops_schema::node::container::LayoutMode;
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
|
||||
|
||||
const LANDING: &str = r#"<html><head><title>Acme</title><style>
|
||||
.hero { display:flex; flex-direction:column; align-items:center; gap:24px;
|
||||
padding:64px; background:linear-gradient(180deg,#0b1220,#1a2740); }
|
||||
.hero h1 { color:#ffffff; margin:0 }
|
||||
.cta { background-color:#3b82f6; color:#ffffff; padding:12px 24px; border-radius:8px }
|
||||
.row { display:flex; gap:16px }
|
||||
</style></head><body>
|
||||
<section class="hero">
|
||||
<h1>Build faster</h1>
|
||||
<p style="color:#94a3b8">Ship <b>beautiful</b> designs</p>
|
||||
<div class="row">
|
||||
<button class="cta">Start</button>
|
||||
<input type="text" placeholder="Email"/>
|
||||
</div>
|
||||
</section>
|
||||
</body></html>"#;
|
||||
|
||||
#[test]
|
||||
fn landing_page_imports_as_editable_tree() {
|
||||
let result = import_html(LANDING, &HtmlImportOptions::default());
|
||||
assert_eq!(result.nodes.len(), 1);
|
||||
let PenNode::Frame(root) = &result.nodes[0] else {
|
||||
panic!("root must be frame")
|
||||
};
|
||||
assert_eq!(root.base.name.as_deref(), Some("Acme"));
|
||||
assert_eq!(root.container.width, Some(SizingBehavior::Number(1440.0)));
|
||||
assert_eq!(
|
||||
root.container.height,
|
||||
Some(SizingBehavior::Keyword(SizingKeyword::FitContent))
|
||||
);
|
||||
let PenNode::Frame(hero) = &root.children.as_ref().unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(hero.container.layout, Some(LayoutMode::Vertical));
|
||||
let children = hero.children.as_ref().unwrap();
|
||||
assert!(children.len() >= 3);
|
||||
let PenNode::Frame(row) = children.last().unwrap() else {
|
||||
panic!("row")
|
||||
};
|
||||
let row_children = row.children.as_ref().unwrap();
|
||||
assert!(matches!(&row_children[0], PenNode::Frame(button)
|
||||
if button.base.role.as_deref() == Some("button")));
|
||||
assert!(matches!(&row_children[1], PenNode::TextInput(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_limit_truncates_with_warning() {
|
||||
let mut html = String::from("<div>");
|
||||
for _ in 0..25_000 {
|
||||
html.push_str("<p>x</p>");
|
||||
}
|
||||
html.push_str("</div>");
|
||||
let result = import_html(&html, &HtmlImportOptions::default());
|
||||
assert!(result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("node limit")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_name_option_overrides_title() {
|
||||
let options = HtmlImportOptions {
|
||||
document_name: Some("Custom".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = import_html(
|
||||
"<html><head><title>T</title></head><body><p>x</p></body></html>",
|
||||
&options,
|
||||
);
|
||||
let PenNode::Frame(root) = &result.nodes[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(root.base.name.as_deref(), Some("Custom"));
|
||||
}
|
||||
|
|
@ -1,6 +1,21 @@
|
|||
//! HTML → PenNode importer (structured path, CSS-subset cascade).
|
||||
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use jian_ops_schema::node::base::PenNodeBase;
|
||||
use jian_ops_schema::node::container::LayoutMode;
|
||||
use jian_ops_schema::node::{FrameNode, PenNode};
|
||||
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
|
||||
use jian_ops_schema::style::{PenFill, SolidFillBody};
|
||||
|
||||
pub mod color;
|
||||
pub mod css;
|
||||
pub mod dom;
|
||||
pub mod length;
|
||||
pub mod mapper;
|
||||
pub mod special;
|
||||
pub mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e_tests;
|
||||
|
||||
pub struct HtmlImportOptions {
|
||||
pub viewport_width: f64,
|
||||
|
|
@ -24,7 +39,6 @@ pub struct HtmlImportResult {
|
|||
}
|
||||
|
||||
pub fn import_html(source: &str, opts: &HtmlImportOptions) -> HtmlImportResult {
|
||||
let _ = opts;
|
||||
let mut warnings = Vec::new();
|
||||
if source.trim().is_empty() {
|
||||
warnings.push("no importable content: input HTML is empty".to_string());
|
||||
|
|
@ -33,14 +47,129 @@ pub fn import_html(source: &str, opts: &HtmlImportOptions) -> HtmlImportResult {
|
|||
warnings,
|
||||
};
|
||||
}
|
||||
// Pipeline lands in later tasks; non-empty input is wired up in Task 11.
|
||||
warnings.push("no importable content: importer pipeline not yet implemented".to_string());
|
||||
let source = truncate_source(source, &mut warnings);
|
||||
let mut parsed = dom::parse_dom(source);
|
||||
if parsed.body.is_empty() {
|
||||
warnings.push("no importable content: input HTML produced an empty body".to_string());
|
||||
return HtmlImportResult {
|
||||
nodes: Vec::new(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
let mut remaining = MAX_OUTPUT_NODES - 1;
|
||||
if truncate_dom_nodes(&mut parsed.body, &mut remaining) {
|
||||
warnings.push("node limit reached (20000), remaining content dropped".to_string());
|
||||
}
|
||||
|
||||
let (mut rules, ua_warnings) = css::cascade::parse_stylesheet(css::cascade::UA_STYLESHEET, 0);
|
||||
warnings.extend(ua_warnings);
|
||||
for (index, stylesheet) in parsed.style_blocks.iter().enumerate() {
|
||||
let (author_rules, stylesheet_warnings) =
|
||||
css::cascade::parse_stylesheet(stylesheet, 1000 + index * 10_000);
|
||||
rules.extend(author_rules);
|
||||
warnings.extend(stylesheet_warnings);
|
||||
}
|
||||
|
||||
let body = dom::DomElement {
|
||||
tag: "body".to_string(),
|
||||
attrs: Vec::new(),
|
||||
children: parsed.body,
|
||||
};
|
||||
let body_style = css::cascade::compute_style(&[&body], &rules, None, opts.base_font_size);
|
||||
let mut context = mapper::MapCtx {
|
||||
opts,
|
||||
rules: &rules,
|
||||
warnings: Vec::new(),
|
||||
next_id: 0,
|
||||
node_count: 1,
|
||||
};
|
||||
let root_id = context.generate_id();
|
||||
let mut container = mapper::container_props_from(&body_style, &mut context);
|
||||
container.width = Some(SizingBehavior::Number(opts.viewport_width));
|
||||
container.height = Some(SizingBehavior::Keyword(SizingKeyword::FitContent));
|
||||
container.layout = Some(LayoutMode::Vertical);
|
||||
if container.fill.is_none() {
|
||||
container.fill = Some(vec![solid_fill("#ffffff")]);
|
||||
}
|
||||
let children = text::map_children(&mut context, &[&body], &body_style, &body.children);
|
||||
let name = opts
|
||||
.document_name
|
||||
.clone()
|
||||
.or(parsed.title)
|
||||
.unwrap_or_else(|| "HTML Import".to_string());
|
||||
let root = PenNode::Frame(FrameNode {
|
||||
base: PenNodeBase {
|
||||
id: root_id,
|
||||
name: Some(name),
|
||||
..Default::default()
|
||||
},
|
||||
container,
|
||||
children: Some(children),
|
||||
image_search_query: None,
|
||||
reusable: None,
|
||||
slot: None,
|
||||
state: None,
|
||||
bindings: None,
|
||||
events: None,
|
||||
lifecycle: None,
|
||||
semantics: None,
|
||||
gestures: None,
|
||||
route: None,
|
||||
screen: None,
|
||||
});
|
||||
warnings.extend(context.warnings);
|
||||
HtmlImportResult {
|
||||
nodes: Vec::new(),
|
||||
nodes: vec![root],
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_INPUT_BYTES: usize = 10 * 1024 * 1024;
|
||||
const MAX_OUTPUT_NODES: usize = 20_000;
|
||||
|
||||
fn truncate_source<'a>(source: &'a str, warnings: &mut Vec<String>) -> &'a str {
|
||||
if source.len() <= MAX_INPUT_BYTES {
|
||||
return source;
|
||||
}
|
||||
let mut end = MAX_INPUT_BYTES;
|
||||
while !source.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
warnings.push("input HTML exceeded 10MB and was truncated".to_string());
|
||||
&source[..end]
|
||||
}
|
||||
|
||||
fn truncate_dom_nodes(nodes: &mut Vec<dom::DomNode>, remaining: &mut usize) -> bool {
|
||||
let original_len = nodes.len();
|
||||
let mut keep = 0;
|
||||
let mut truncated = false;
|
||||
for node in nodes.iter_mut() {
|
||||
if *remaining == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
*remaining -= 1;
|
||||
keep += 1;
|
||||
if let dom::DomNode::Element(element) = node {
|
||||
truncated |= truncate_dom_nodes(&mut element.children, remaining);
|
||||
}
|
||||
}
|
||||
if keep < original_len {
|
||||
nodes.truncate(keep);
|
||||
truncated = true;
|
||||
}
|
||||
truncated
|
||||
}
|
||||
|
||||
fn solid_fill(color: &str) -> PenFill {
|
||||
PenFill::Solid(SolidFillBody {
|
||||
color: color.to_string(),
|
||||
explain: None,
|
||||
opacity: None,
|
||||
blend_mode: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
Loading…
Reference in a new issue