//! DOM-JS bridge: exposes the DOM `Document` to JavaScript. //! //! Registers the `document` global object and provides native functions for //! element access, query methods, factory methods, and element wrapper //! properties. use crate::builtins::{make_native, set_builtin_prop}; use crate::gc::{Gc, GcRef}; use crate::vm::*; use std::rc::Rc; use we_css::parser::Parser as CssParser; use we_dom::{Document, NodeData, NodeId}; use we_html::parse_html; use we_style::matching::matches_selector_list; /// Native callback type alias. type NativeMethod = ( &'static str, fn(&[Value], &mut NativeContext) -> Result, ); // ── Internal property key for storing NodeId on wrapper objects ────── const NODE_ID_KEY: &str = "__node_id__"; // ── Node wrapper creation ─────────────────────────────────────────── /// Get or create a JS wrapper object for a DOM node. Returns the same /// `GcRef` for the same `NodeId` (wrapper identity). fn get_or_create_wrapper( node_id: NodeId, gc: &mut Gc, bridge: &DomBridge, object_proto: Option, ) -> GcRef { let idx = node_id.index(); if let Some(&existing) = bridge.node_wrappers.borrow().get(&idx) { // Verify the GcRef is still alive. if gc.get(existing).is_some() { return existing; } } let doc = bridge.document.borrow(); let mut data = ObjectData::new(); if let Some(proto) = object_proto { data.prototype = Some(proto); } // Store the NodeId index as an internal property. data.properties.insert( NODE_ID_KEY.to_string(), Property::builtin(Value::Number(idx as f64)), ); // Populate properties based on node type. match doc.node_data(node_id) { NodeData::Element { tag_name, attributes, .. } => { let upper_tag = tag_name.to_ascii_uppercase(); data.properties.insert( "tagName".to_string(), Property::builtin(Value::String(upper_tag.clone())), ); data.properties.insert( "nodeName".to_string(), Property::builtin(Value::String(upper_tag)), ); data.properties.insert( "nodeType".to_string(), Property::builtin(Value::Number(1.0)), ); // id attribute let id_val = attributes .iter() .find(|a| a.name == "id") .map(|a| Value::String(a.value.clone())) .unwrap_or(Value::String(String::new())); data.properties .insert("id".to_string(), Property::builtin(id_val)); // className attribute let class_val = attributes .iter() .find(|a| a.name == "class") .map(|a| Value::String(a.value.clone())) .unwrap_or(Value::String(String::new())); data.properties .insert("className".to_string(), Property::builtin(class_val)); } NodeData::Text { .. } => { data.properties.insert( "nodeName".to_string(), Property::builtin(Value::String("#text".to_string())), ); data.properties.insert( "nodeType".to_string(), Property::builtin(Value::Number(3.0)), ); } NodeData::Comment { .. } => { data.properties.insert( "nodeName".to_string(), Property::builtin(Value::String("#comment".to_string())), ); data.properties.insert( "nodeType".to_string(), Property::builtin(Value::Number(8.0)), ); } NodeData::Document => { data.properties.insert( "nodeName".to_string(), Property::builtin(Value::String("#document".to_string())), ); data.properties.insert( "nodeType".to_string(), Property::builtin(Value::Number(9.0)), ); } } let gc_ref = gc.alloc(HeapObject::Object(data)); // Register DOM methods on the wrapper based on node type. register_node_methods(gc, gc_ref, &doc, node_id); bridge.node_wrappers.borrow_mut().insert(idx, gc_ref); gc_ref } // ── Helper: walk DOM tree ─────────────────────────────────────────── fn walk_tree(doc: &Document, root: NodeId, visitor: &mut dyn FnMut(NodeId) -> bool) { let mut stack = vec![root]; while let Some(node) = stack.pop() { if visitor(node) { return; } // Push children in reverse order so first child is visited first. let mut children: Vec = doc.children(node).collect(); children.reverse(); stack.extend(children); } } // ── Helper: make an array of wrapper Values ───────────────────────── fn make_wrapper_array( nodes: &[NodeId], gc: &mut Gc, bridge: &DomBridge, object_proto: Option, ) -> Value { let mut obj = ObjectData::new(); for (i, &nid) in nodes.iter().enumerate() { let wrapper = get_or_create_wrapper(nid, gc, bridge, object_proto); obj.properties .insert(i.to_string(), Property::data(Value::Object(wrapper))); } obj.properties.insert( "length".to_string(), Property { value: Value::Number(nodes.len() as f64), writable: true, enumerable: false, configurable: false, }, ); Value::Object(gc.alloc(HeapObject::Object(obj))) } // ── Document global setup ─────────────────────────────────────────── /// Register the `document` global object on the VM. Called from /// `Vm::attach_document`. pub fn init_document_object(vm: &mut Vm) { let mut data = ObjectData::new(); if let Some(proto) = vm.object_prototype { data.prototype = Some(proto); } // nodeType 9 = Document data.properties.insert( "nodeType".to_string(), Property::builtin(Value::Number(9.0)), ); data.properties.insert( "nodeName".to_string(), Property::builtin(Value::String("#document".to_string())), ); // Set document.title from the DOM. if let Some(bridge) = &vm.dom_bridge { let doc = bridge.document.borrow(); let title = find_title_text(&doc); data.properties .insert("title".to_string(), Property::builtin(Value::String(title))); } let doc_ref = vm.gc.alloc(HeapObject::Object(data)); // Set documentElement, head, body as wrapper properties. if let Some(bridge) = &vm.dom_bridge { let (html_id, head_id, body_id) = { let doc = bridge.document.borrow(); find_structural_elements(&doc) }; if let Some(html) = html_id { let wrapper = get_or_create_wrapper(html, &mut vm.gc, bridge, vm.object_prototype); set_builtin_prop( &mut vm.gc, doc_ref, "documentElement", Value::Object(wrapper), ); } if let Some(head) = head_id { let wrapper = get_or_create_wrapper(head, &mut vm.gc, bridge, vm.object_prototype); set_builtin_prop(&mut vm.gc, doc_ref, "head", Value::Object(wrapper)); } if let Some(body) = body_id { let wrapper = get_or_create_wrapper(body, &mut vm.gc, bridge, vm.object_prototype); set_builtin_prop(&mut vm.gc, doc_ref, "body", Value::Object(wrapper)); } } // Register methods on the document object. let methods: &[NativeMethod] = &[ ("getElementById", doc_get_element_by_id), ("getElementsByTagName", doc_get_elements_by_tag_name), ("getElementsByClassName", doc_get_elements_by_class_name), ("querySelector", doc_query_selector), ("querySelectorAll", doc_query_selector_all), ("createElement", doc_create_element), ("createTextNode", doc_create_text_node), ]; for &(name, callback) in methods { let func = make_native(&mut vm.gc, name, callback); set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func)); } vm.set_global("document", Value::Object(doc_ref)); } /// Find ``, ``, and `` elements in the document. fn find_structural_elements(doc: &Document) -> (Option, Option, Option) { let mut html = None; let mut head = None; let mut body = None; for child in doc.children(doc.root()) { if let NodeData::Element { tag_name, .. } = doc.node_data(child) { if tag_name.eq_ignore_ascii_case("html") { html = Some(child); for inner in doc.children(child) { if let NodeData::Element { tag_name, .. } = doc.node_data(inner) { if tag_name.eq_ignore_ascii_case("head") { head = Some(inner); } else if tag_name.eq_ignore_ascii_case("body") { body = Some(inner); } } } break; } } } (html, head, body) } /// Extract the text content of `` from the document. fn find_title_text(doc: &Document) -> String { let mut result = String::new(); walk_tree(doc, doc.root(), &mut |node| { if let NodeData::Element { tag_name, .. } = doc.node_data(node) { if tag_name.eq_ignore_ascii_case("title") { // Collect text children. for child in doc.children(node) { if let Some(text) = doc.text_content(child) { result.push_str(text); } } return true; // stop walking } } false }); result } // ── Document methods ──────────────────────────────────────────────── fn doc_get_element_by_id(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let id = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Ok(Value::Null), }; let found = { let doc = bridge.document.borrow(); let mut found = None; walk_tree(&doc, doc.root(), &mut |node| { if let Some(attr_val) = doc.get_attribute(node, "id") { if attr_val == id { found = Some(node); return true; } } false }); found }; match found { Some(node_id) => { let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); Ok(Value::Object(wrapper)) } None => Ok(Value::Null), } } fn doc_get_elements_by_tag_name( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let tag = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Ok(make_empty_array(ctx.gc)), }; let matches = { let doc = bridge.document.borrow(); let mut matches = Vec::new(); walk_tree(&doc, doc.root(), &mut |node| { if let NodeData::Element { tag_name: tn, .. } = doc.node_data(node) { if tn.eq_ignore_ascii_case(&tag) || tag == "*" { matches.push(node); } } false }); matches }; Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) } fn doc_get_elements_by_class_name( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let class_name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Ok(make_empty_array(ctx.gc)), }; let matches = { let doc = bridge.document.borrow(); let mut matches = Vec::new(); walk_tree(&doc, doc.root(), &mut |node| { if let Some(cls) = doc.get_attribute(node, "class") { if cls.split_whitespace().any(|c| c == class_name) { matches.push(node); } } false }); matches }; Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) } fn doc_query_selector(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let selector_str = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Ok(Value::Null), }; let selector_list = CssParser::parse_selectors(&selector_str); if selector_list.selectors.is_empty() { return Ok(Value::Null); } let found = { let doc = bridge.document.borrow(); let mut found = None; walk_tree(&doc, doc.root(), &mut |node| { if matches!(doc.node_data(node), NodeData::Element { .. }) && matches_selector_list(&doc, node, &selector_list) { found = Some(node); return true; } false }); found }; match found { Some(node_id) => { let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); Ok(Value::Object(wrapper)) } None => Ok(Value::Null), } } fn doc_query_selector_all(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let selector_str = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Ok(make_empty_array(ctx.gc)), }; let selector_list = CssParser::parse_selectors(&selector_str); let matches = { let doc = bridge.document.borrow(); let mut matches = Vec::new(); walk_tree(&doc, doc.root(), &mut |node| { if matches!(doc.node_data(node), NodeData::Element { .. }) && matches_selector_list(&doc, node, &selector_list) { matches.push(node); } false }); matches }; Ok(make_wrapper_array(&matches, ctx.gc, bridge, None)) } fn doc_create_element(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let tag = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Err(RuntimeError::type_error("no document attached")), }; let node_id = bridge.document.borrow_mut().create_element(&tag); let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); Ok(Value::Object(wrapper)) } fn doc_create_text_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let text = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let bridge = match ctx.dom_bridge { Some(b) => b, None => return Err(RuntimeError::type_error("no document attached")), }; let node_id = bridge.document.borrow_mut().create_text(&text); let wrapper = get_or_create_wrapper(node_id, ctx.gc, bridge, None); Ok(Value::Object(wrapper)) } /// Create an empty JS array. fn make_empty_array(gc: &mut Gc<HeapObject>) -> Value { let mut obj = ObjectData::new(); obj.properties.insert( "length".to_string(), Property { value: Value::Number(0.0), writable: true, enumerable: false, configurable: false, }, ); Value::Object(gc.alloc(HeapObject::Object(obj))) } // ── Method registration on wrappers ────────────────────────────────── /// Register DOM methods on a wrapper object based on node type. fn register_node_methods(gc: &mut Gc<HeapObject>, wrapper: GcRef, doc: &Document, node_id: NodeId) { // Methods available on all node types. let node_methods: &[NativeMethod] = &[ ("appendChild", node_append_child), ("removeChild", node_remove_child), ("insertBefore", node_insert_before), ("replaceChild", node_replace_child), ("cloneNode", node_clone_node), ("hasChildNodes", node_has_child_nodes), ("addEventListener", event_target_add_listener), ("removeEventListener", event_target_remove_listener), ("dispatchEvent", event_target_dispatch_event), ]; for &(name, callback) in node_methods { let func = make_native(gc, name, callback); set_builtin_prop(gc, wrapper, name, Value::Function(func)); } // Methods available only on Element nodes. if matches!(doc.node_data(node_id), NodeData::Element { .. }) { let element_methods: &[NativeMethod] = &[ ("getAttribute", element_get_attribute), ("setAttribute", element_set_attribute), ("removeAttribute", element_remove_attribute), ("hasAttribute", element_has_attribute), ]; for &(name, callback) in element_methods { let func = make_native(gc, name, callback); set_builtin_prop(gc, wrapper, name, Value::Function(func)); } } } // ── Helper: extract NodeId from a wrapper ─────────────────────────── fn get_node_id(gc: &Gc<HeapObject>, wrapper: GcRef) -> Option<NodeId> { match gc.get(wrapper) { Some(HeapObject::Object(data)) => match data.properties.get(NODE_ID_KEY) { Some(Property { value: Value::Number(n), .. }) => Some(NodeId::from_index(*n as usize)), _ => None, }, _ => None, } } /// Extract a `NodeId` from a JS Value that should be a DOM wrapper object. fn value_to_node_id(gc: &Gc<HeapObject>, val: &Value) -> Option<NodeId> { match val { Value::Object(r) => get_node_id(gc, *r), _ => None, } } // ── Node manipulation methods ─────────────────────────────────────── fn node_append_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let parent_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("appendChild called on non-node"))?; let child_val = args .first() .ok_or_else(|| RuntimeError::type_error("appendChild requires an argument"))?; let child_id = value_to_node_id(ctx.gc, child_val) .ok_or_else(|| RuntimeError::type_error("appendChild argument is not a node"))?; bridge .document .borrow_mut() .append_child(parent_id, child_id); Ok(child_val.clone()) } fn node_remove_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let parent_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("removeChild called on non-node"))?; let child_val = args .first() .ok_or_else(|| RuntimeError::type_error("removeChild requires an argument"))?; let child_id = value_to_node_id(ctx.gc, child_val) .ok_or_else(|| RuntimeError::type_error("removeChild argument is not a node"))?; bridge .document .borrow_mut() .remove_child(parent_id, child_id); Ok(child_val.clone()) } fn node_insert_before(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let parent_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("insertBefore called on non-node"))?; let new_node_val = args .first() .ok_or_else(|| RuntimeError::type_error("insertBefore requires two arguments"))?; let new_node_id = value_to_node_id(ctx.gc, new_node_val) .ok_or_else(|| RuntimeError::type_error("insertBefore: first argument is not a node"))?; let ref_val = args.get(1).cloned().unwrap_or(Value::Null); if matches!(ref_val, Value::Null) { // insertBefore with null reference = appendChild bridge .document .borrow_mut() .append_child(parent_id, new_node_id); } else { let ref_id = value_to_node_id(ctx.gc, &ref_val).ok_or_else(|| { RuntimeError::type_error("insertBefore: second argument is not a node") })?; bridge .document .borrow_mut() .insert_before(parent_id, new_node_id, ref_id); } Ok(new_node_val.clone()) } fn node_replace_child(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let parent_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("replaceChild called on non-node"))?; let new_child_val = args .first() .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?; let new_child_id = value_to_node_id(ctx.gc, new_child_val) .ok_or_else(|| RuntimeError::type_error("replaceChild: first argument is not a node"))?; let old_child_val = args .get(1) .ok_or_else(|| RuntimeError::type_error("replaceChild requires two arguments"))?; let old_child_id = value_to_node_id(ctx.gc, old_child_val) .ok_or_else(|| RuntimeError::type_error("replaceChild: second argument is not a node"))?; bridge .document .borrow_mut() .replace_child(parent_id, new_child_id, old_child_id); Ok(old_child_val.clone()) } fn node_clone_node(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("cloneNode called on non-node"))?; let deep = args.first().map(|v| v.to_boolean()).unwrap_or(false); let cloned_id = bridge.document.borrow_mut().clone_node(node_id, deep); let wrapper = get_or_create_wrapper(cloned_id, ctx.gc, bridge, None); Ok(Value::Object(wrapper)) } fn node_has_child_nodes(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let _ = args; let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("hasChildNodes called on non-node"))?; let has = bridge.document.borrow().has_child_nodes(node_id); Ok(Value::Boolean(has)) } // ── Attribute methods ─────────────────────────────────────────────── fn element_get_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("getAttribute called on non-element"))?; let name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let doc = bridge.document.borrow(); match doc.get_attribute(node_id, &name) { Some(val) => Ok(Value::String(val.to_string())), None => Ok(Value::Null), } } fn element_set_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("setAttribute called on non-element"))?; let name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let value = args .get(1) .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); bridge .document .borrow_mut() .set_attribute(node_id, &name, &value); // Sync special attributes to wrapper properties. if let Value::Object(wrapper) = &ctx.this { if name == "id" { set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(value.clone())); } else if name == "class" { set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(value.clone())); } } Ok(Value::Undefined) } fn element_remove_attribute( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("removeAttribute called on non-element"))?; let name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); bridge .document .borrow_mut() .remove_attribute(node_id, &name); // Sync to wrapper. if let Value::Object(wrapper) = &ctx.this { if name == "id" { set_builtin_prop(ctx.gc, *wrapper, "id", Value::String(String::new())); } else if name == "class" { set_builtin_prop(ctx.gc, *wrapper, "className", Value::String(String::new())); } } Ok(Value::Undefined) } fn element_has_attribute(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("hasAttribute called on non-element"))?; let name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let doc = bridge.document.borrow(); let has = doc.get_attribute(node_id, &name).is_some(); Ok(Value::Boolean(has)) } // ── HTML serialization ────────────────────────────────────────────── /// Serialize the children of a node to HTML (for innerHTML getter). fn serialize_children(doc: &Document, node: NodeId) -> String { let mut html = String::new(); for child in doc.children(node) { serialize_node_to(doc, child, &mut html); } html } /// Serialize a node and its subtree to HTML (for outerHTML getter). fn serialize_node_html(doc: &Document, node: NodeId) -> String { let mut html = String::new(); serialize_node_to(doc, node, &mut html); html } fn serialize_node_to(doc: &Document, node: NodeId, out: &mut String) { match doc.node_data(node) { NodeData::Element { tag_name, attributes, .. } => { out.push('<'); out.push_str(tag_name); for attr in attributes { out.push(' '); out.push_str(&attr.name); out.push_str("=\""); escape_attr(&attr.value, out); out.push('"'); } out.push('>'); // Void elements don't have closing tags. if !is_void_element(tag_name) { for child in doc.children(node) { serialize_node_to(doc, child, out); } out.push_str("</"); out.push_str(tag_name); out.push('>'); } } NodeData::Text { data } => { escape_text(data, out); } NodeData::Comment { data } => { out.push_str("<!--"); out.push_str(data); out.push_str("-->"); } NodeData::Document => { for child in doc.children(node) { serialize_node_to(doc, child, out); } } } } fn escape_text(s: &str, out: &mut String) { for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), _ => out.push(c), } } } fn escape_attr(s: &str, out: &mut String) { for c in s.chars() { match c { '&' => out.push_str("&"), '"' => out.push_str("""), _ => out.push(c), } } } fn is_void_element(tag: &str) -> bool { matches!( tag.to_ascii_lowercase().as_str(), "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link" | "meta" | "param" | "source" | "track" | "wbr" ) } // ── Dynamic DOM property resolution ───────────────────────────────── /// Resolve a dynamic DOM property for a wrapper object. /// Called from the VM when a property is not found in the static properties. /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. pub fn resolve_dom_get( gc: &mut Gc<HeapObject>, bridge: &Rc<DomBridge>, gc_ref: GcRef, key: &str, ) -> Option<Value> { let node_id = get_node_id(gc, gc_ref)?; let doc = bridge.document.borrow(); match key { // ── Navigation properties ──────────────── "parentNode" => { let parent = doc.parent(node_id); drop(doc); Some(match parent { Some(p) => Value::Object(get_or_create_wrapper(p, gc, bridge, None)), None => Value::Null, }) } "parentElement" => { let parent = doc.parent(node_id); let is_element = parent .map(|p| matches!(doc.node_data(p), NodeData::Element { .. })) .unwrap_or(false); drop(doc); Some(if is_element { Value::Object(get_or_create_wrapper(parent.unwrap(), gc, bridge, None)) } else { Value::Null }) } "childNodes" => { let children: Vec<NodeId> = doc.children(node_id).collect(); drop(doc); Some(make_wrapper_array(&children, gc, bridge, None)) } "children" => { let children: Vec<NodeId> = doc .children(node_id) .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. })) .collect(); drop(doc); Some(make_wrapper_array(&children, gc, bridge, None)) } "firstChild" => { let fc = doc.first_child(node_id); drop(doc); Some(match fc { Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), None => Value::Null, }) } "lastChild" => { let lc = doc.last_child(node_id); drop(doc); Some(match lc { Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), None => Value::Null, }) } "firstElementChild" => { let fc = doc .children(node_id) .find(|&c| matches!(doc.node_data(c), NodeData::Element { .. })); drop(doc); Some(match fc { Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), None => Value::Null, }) } "lastElementChild" => { let children: Vec<NodeId> = doc .children(node_id) .filter(|&c| matches!(doc.node_data(c), NodeData::Element { .. })) .collect(); let last = children.last().copied(); drop(doc); Some(match last { Some(c) => Value::Object(get_or_create_wrapper(c, gc, bridge, None)), None => Value::Null, }) } "nextSibling" => { let ns = doc.next_sibling(node_id); drop(doc); Some(match ns { Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)), None => Value::Null, }) } "previousSibling" => { let ps = doc.prev_sibling(node_id); drop(doc); Some(match ps { Some(s) => Value::Object(get_or_create_wrapper(s, gc, bridge, None)), None => Value::Null, }) } "nextElementSibling" => { let mut current = doc.next_sibling(node_id); while let Some(s) = current { if matches!(doc.node_data(s), NodeData::Element { .. }) { drop(doc); return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None))); } current = doc.next_sibling(s); } Some(Value::Null) } "previousElementSibling" => { let mut current = doc.prev_sibling(node_id); while let Some(s) = current { if matches!(doc.node_data(s), NodeData::Element { .. }) { drop(doc); return Some(Value::Object(get_or_create_wrapper(s, gc, bridge, None))); } current = doc.prev_sibling(s); } Some(Value::Null) } // ── Content properties ─────────────────── "textContent" => { let text = doc.deep_text_content(node_id); Some(Value::String(text)) } "innerHTML" => { if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { return None; } let html = serialize_children(&doc, node_id); Some(Value::String(html)) } "outerHTML" => { if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { return None; } let html = serialize_node_html(&doc, node_id); Some(Value::String(html)) } // ── Attribute array ────────────────────── "attributes" => { if let Some(attrs) = doc.attributes(node_id) { let mut obj = ObjectData::new(); for (i, attr) in attrs.iter().enumerate() { let mut attr_obj = ObjectData::new(); attr_obj.properties.insert( "name".to_string(), Property::data(Value::String(attr.name.clone())), ); attr_obj.properties.insert( "value".to_string(), Property::data(Value::String(attr.value.clone())), ); let attr_ref = gc.alloc(HeapObject::Object(attr_obj)); obj.properties .insert(i.to_string(), Property::data(Value::Object(attr_ref))); } obj.properties.insert( "length".to_string(), Property { value: Value::Number(doc.attributes(node_id).map_or(0, |a| a.len()) as f64), writable: false, enumerable: false, configurable: false, }, ); drop(doc); Some(Value::Object(gc.alloc(HeapObject::Object(obj)))) } else { None } } // ── classList ──────────────────────────── "classList" => { if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { return None; } drop(doc); Some(create_class_list(gc, bridge, node_id)) } // ── style ──────────────────────────────── "style" => { if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { return None; } let style_str = doc .get_attribute(node_id, "style") .unwrap_or("") .to_string(); drop(doc); Some(create_style_object(gc, bridge, node_id, &style_str)) } _ => None, } } /// Handle a DOM property set on a wrapper object. /// Returns `true` if the key was handled (caller should skip normal property set). pub fn handle_dom_set( gc: &mut Gc<HeapObject>, bridge: &Rc<DomBridge>, gc_ref: GcRef, key: &str, val: &Value, ) -> bool { let node_id = match get_node_id(gc, gc_ref) { Some(id) => id, None => return false, }; match key { "textContent" => { let text = val.to_js_string(gc); bridge .document .borrow_mut() .set_element_text_content(node_id, &text); true } "innerHTML" => { let html_str = val.to_js_string(gc); let mut doc = bridge.document.borrow_mut(); // Remove existing children. while let Some(child) = doc.first_child(node_id) { doc.remove_child(node_id, child); } drop(doc); // Parse the HTML fragment and adopt children into the real document. let fragment_doc = parse_html(&format!("<body>{html_str}</body>")); let frag_root = fragment_doc.root(); // Find the <body> element in the parsed fragment. let body = fragment_doc.children(frag_root).find(|&c| { matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("html")) }).and_then(|html| { fragment_doc.children(html).find(|&c| { matches!(fragment_doc.node_data(c), NodeData::Element { tag_name, .. } if tag_name.eq_ignore_ascii_case("body")) }) }); if let Some(body_id) = body { adopt_children( &fragment_doc, body_id, &mut bridge.document.borrow_mut(), node_id, ); } true } "id" => { let id_val = val.to_js_string(gc); bridge .document .borrow_mut() .set_attribute(node_id, "id", &id_val); // Also update the wrapper's static `id` property. set_builtin_prop(gc, gc_ref, "id", Value::String(id_val)); true } "className" => { let class_val = val.to_js_string(gc); bridge .document .borrow_mut() .set_attribute(node_id, "class", &class_val); set_builtin_prop(gc, gc_ref, "className", Value::String(class_val)); true } _ => false, } } /// Recursively copy children from a parsed fragment document into the real document. fn adopt_children( src_doc: &Document, src_parent: NodeId, dst_doc: &mut Document, dst_parent: NodeId, ) { for child in src_doc.children(src_parent) { let new_node = match src_doc.node_data(child) { NodeData::Element { tag_name, attributes, .. } => { let el = dst_doc.create_element(tag_name); for attr in attributes { dst_doc.set_attribute(el, &attr.name, &attr.value); } el } NodeData::Text { data } => dst_doc.create_text(data), NodeData::Comment { data } => dst_doc.create_comment(data), NodeData::Document => continue, }; dst_doc.append_child(dst_parent, new_node); // Recurse for element children. if matches!(src_doc.node_data(child), NodeData::Element { .. }) { adopt_children(src_doc, child, dst_doc, new_node); } } } /// Check if a DOM property set should be intercepted for a style proxy object. /// Returns `true` if handled. pub fn handle_style_set( gc: &mut Gc<HeapObject>, bridge: &Rc<DomBridge>, gc_ref: GcRef, key: &str, val: &Value, ) -> bool { // Check for __style_node_id__ marker. let node_id = match gc.get(gc_ref) { Some(HeapObject::Object(data)) => match data.properties.get("__style_node_id__") { Some(Property { value: Value::Number(n), .. }) => NodeId::from_index(*n as usize), _ => return false, }, _ => return false, }; // Set the property on the style object normally. if let Some(HeapObject::Object(data)) = gc.get_mut(gc_ref) { data.properties .insert(key.to_string(), Property::data(val.clone())); } // Serialize all style properties back to the element's style attribute. let style_str = serialize_style_object(gc, gc_ref); bridge .document .borrow_mut() .set_attribute(node_id, "style", &style_str); true } // ── classList helpers ─────────────────────────────────────────────── fn create_class_list(gc: &mut Gc<HeapObject>, _bridge: &DomBridge, node_id: NodeId) -> Value { let mut data = ObjectData::new(); // Store the node ID for method callbacks. data.properties.insert( NODE_ID_KEY.to_string(), Property::builtin(Value::Number(node_id.index() as f64)), ); let gc_ref = gc.alloc(HeapObject::Object(data)); let methods: &[NativeMethod] = &[ ("add", class_list_add), ("remove", class_list_remove), ("toggle", class_list_toggle), ("contains", class_list_contains), ]; for &(name, callback) in methods { let func = make_native(gc, name, callback); set_builtin_prop(gc, gc_ref, name, Value::Function(func)); } Value::Object(gc_ref) } fn get_class_list_node_id(gc: &Gc<HeapObject>, this: &Value) -> Option<NodeId> { match this { Value::Object(r) => get_node_id(gc, *r), _ => None, } } fn class_list_add(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document"))?; let node_id = get_class_list_node_id(ctx.gc, &ctx.this) .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; for arg in args { let class_name = arg.to_js_string(ctx.gc); let doc = bridge.document.borrow(); let current = doc .get_attribute(node_id, "class") .unwrap_or("") .to_string(); drop(doc); if !current.split_whitespace().any(|c| c == class_name) { let new_val = if current.is_empty() { class_name } else { format!("{current} {class_name}") }; bridge .document .borrow_mut() .set_attribute(node_id, "class", &new_val); } } Ok(Value::Undefined) } fn class_list_remove(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document"))?; let node_id = get_class_list_node_id(ctx.gc, &ctx.this) .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; for arg in args { let class_name = arg.to_js_string(ctx.gc); let doc = bridge.document.borrow(); let current = doc .get_attribute(node_id, "class") .unwrap_or("") .to_string(); drop(doc); let new_val: Vec<&str> = current .split_whitespace() .filter(|&c| c != class_name) .collect(); bridge .document .borrow_mut() .set_attribute(node_id, "class", &new_val.join(" ")); } Ok(Value::Undefined) } fn class_list_toggle(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document"))?; let node_id = get_class_list_node_id(ctx.gc, &ctx.this) .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; let class_name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let doc = bridge.document.borrow(); let current = doc .get_attribute(node_id, "class") .unwrap_or("") .to_string(); drop(doc); let has_class = current.split_whitespace().any(|c| c == class_name); if has_class { let new_val: Vec<&str> = current .split_whitespace() .filter(|&c| c != class_name) .collect(); bridge .document .borrow_mut() .set_attribute(node_id, "class", &new_val.join(" ")); Ok(Value::Boolean(false)) } else { let new_val = if current.is_empty() { class_name } else { format!("{current} {class_name}") }; bridge .document .borrow_mut() .set_attribute(node_id, "class", &new_val); Ok(Value::Boolean(true)) } } fn class_list_contains(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document"))?; let node_id = get_class_list_node_id(ctx.gc, &ctx.this) .ok_or_else(|| RuntimeError::type_error("invalid classList"))?; let class_name = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let doc = bridge.document.borrow(); let current = doc.get_attribute(node_id, "class").unwrap_or(""); let has = current.split_whitespace().any(|c| c == class_name); Ok(Value::Boolean(has)) } // ── Style object helpers ──────────────────────────────────────────── fn create_style_object( gc: &mut Gc<HeapObject>, _bridge: &DomBridge, node_id: NodeId, style_str: &str, ) -> Value { let mut data = ObjectData::new(); // Marker for style proxy interception. data.properties.insert( "__style_node_id__".to_string(), Property::builtin(Value::Number(node_id.index() as f64)), ); // Parse existing inline style into camelCase properties. for decl in style_str.split(';') { let decl = decl.trim(); if decl.is_empty() { continue; } if let Some((prop, val)) = decl.split_once(':') { let camel = kebab_to_camel(prop.trim()); data.properties .insert(camel, Property::data(Value::String(val.trim().to_string()))); } } Value::Object(gc.alloc(HeapObject::Object(data))) } fn serialize_style_object(gc: &Gc<HeapObject>, style_ref: GcRef) -> String { let mut parts = Vec::new(); if let Some(HeapObject::Object(data)) = gc.get(style_ref) { for (key, prop) in &data.properties { if key.starts_with("__") { continue; } let kebab = camel_to_kebab(key); let val = match &prop.value { Value::String(s) => s.clone(), Value::Number(n) => format!("{n}"), _ => continue, }; parts.push(format!("{kebab}: {val}")); } } parts.join("; ") } fn kebab_to_camel(s: &str) -> String { let mut result = String::new(); let mut next_upper = false; for c in s.chars() { if c == '-' { next_upper = true; } else if next_upper { result.extend(c.to_uppercase()); next_upper = false; } else { result.push(c); } } result } fn camel_to_kebab(s: &str) -> String { let mut result = String::new(); for c in s.chars() { if c.is_ascii_uppercase() { result.push('-'); result.push(c.to_ascii_lowercase()); } else { result.push(c); } } result } // ── Event system ──────────────────────────────────────────────────── // Internal property keys for Event objects. const EVENT_TYPE_KEY: &str = "__event_type__"; const EVENT_BUBBLES_KEY: &str = "__event_bubbles__"; const EVENT_CANCELABLE_KEY: &str = "__event_cancelable__"; const EVENT_STOP_PROP_KEY: &str = "__event_stop_prop__"; const EVENT_STOP_IMMEDIATE_KEY: &str = "__event_stop_immediate__"; const EVENT_DEFAULT_PREVENTED_KEY: &str = "__event_default_prevented__"; const EVENT_PHASE_KEY: &str = "__event_phase__"; /// Create an Event JS object with the given type string and options. fn create_event_object( gc: &mut Gc<HeapObject>, event_type: &str, bubbles: bool, cancelable: bool, ) -> GcRef { let mut data = ObjectData::new(); // Public properties. data.properties.insert( "type".to_string(), Property::data(Value::String(event_type.to_string())), ); data.properties.insert( "bubbles".to_string(), Property::data(Value::Boolean(bubbles)), ); data.properties.insert( "cancelable".to_string(), Property::data(Value::Boolean(cancelable)), ); data.properties.insert( "defaultPrevented".to_string(), Property::data(Value::Boolean(false)), ); data.properties .insert("eventPhase".to_string(), Property::data(Value::Number(0.0))); data.properties .insert("target".to_string(), Property::data(Value::Null)); data.properties .insert("currentTarget".to_string(), Property::data(Value::Null)); data.properties .insert("timeStamp".to_string(), Property::data(Value::Number(0.0))); // Internal state. data.properties.insert( EVENT_TYPE_KEY.to_string(), Property::builtin(Value::String(event_type.to_string())), ); data.properties.insert( EVENT_BUBBLES_KEY.to_string(), Property::builtin(Value::Boolean(bubbles)), ); data.properties.insert( EVENT_CANCELABLE_KEY.to_string(), Property::builtin(Value::Boolean(cancelable)), ); data.properties.insert( EVENT_STOP_PROP_KEY.to_string(), Property::builtin(Value::Boolean(false)), ); data.properties.insert( EVENT_STOP_IMMEDIATE_KEY.to_string(), Property::builtin(Value::Boolean(false)), ); data.properties.insert( EVENT_DEFAULT_PREVENTED_KEY.to_string(), Property::builtin(Value::Boolean(false)), ); data.properties.insert( EVENT_PHASE_KEY.to_string(), Property::builtin(Value::Number(0.0)), ); // Event phase constants. data.properties .insert("NONE".to_string(), Property::builtin(Value::Number(0.0))); data.properties.insert( "CAPTURING_PHASE".to_string(), Property::builtin(Value::Number(1.0)), ); data.properties.insert( "AT_TARGET".to_string(), Property::builtin(Value::Number(2.0)), ); data.properties.insert( "BUBBLING_PHASE".to_string(), Property::builtin(Value::Number(3.0)), ); let event_ref = gc.alloc(HeapObject::Object(data)); // Register methods. let methods: &[NativeMethod] = &[ ("preventDefault", event_prevent_default), ("stopPropagation", event_stop_propagation), ("stopImmediatePropagation", event_stop_immediate_propagation), ]; for &(name, callback) in methods { let func = make_native(gc, name, callback); set_builtin_prop(gc, event_ref, name, Value::Function(func)); } event_ref } /// `Event` constructor: `new Event(type, options)`. fn event_constructor(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { let event_type = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let mut bubbles = false; let mut cancelable = false; // Parse options object if provided. if let Some(Value::Object(opts_ref)) = args.get(1) { if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { if let Some(prop) = opts.properties.get("bubbles") { bubbles = prop.value.to_boolean(); } if let Some(prop) = opts.properties.get("cancelable") { cancelable = prop.value.to_boolean(); } } } let event_ref = create_event_object(ctx.gc, &event_type, bubbles, cancelable); Ok(Value::Object(event_ref)) } fn event_prevent_default(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { if let Value::Object(r) = &ctx.this { // Only prevent if cancelable. let cancelable = match ctx.gc.get(*r) { Some(HeapObject::Object(data)) => data .properties .get(EVENT_CANCELABLE_KEY) .map(|p| p.value.to_boolean()) .unwrap_or(false), _ => false, }; if cancelable { set_builtin_prop( ctx.gc, *r, EVENT_DEFAULT_PREVENTED_KEY, Value::Boolean(true), ); set_builtin_prop(ctx.gc, *r, "defaultPrevented", Value::Boolean(true)); } } Ok(Value::Undefined) } fn event_stop_propagation(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { if let Value::Object(r) = &ctx.this { set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true)); } Ok(Value::Undefined) } fn event_stop_immediate_propagation( _args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { if let Value::Object(r) = &ctx.this { set_builtin_prop(ctx.gc, *r, EVENT_STOP_PROP_KEY, Value::Boolean(true)); set_builtin_prop(ctx.gc, *r, EVENT_STOP_IMMEDIATE_KEY, Value::Boolean(true)); } Ok(Value::Undefined) } // ── EventTarget methods ───────────────────────────────────────────── fn event_target_add_listener( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("addEventListener called on non-node"))?; let event_type = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let callback_ref = match args.get(1) { Some(Value::Function(r)) => *r, _ => return Ok(Value::Undefined), // Silently ignore non-function }; let mut capture = false; let mut once = false; // Third arg: boolean (capture) or options object. if let Some(arg) = args.get(2) { match arg { Value::Boolean(b) => capture = *b, Value::Object(opts_ref) => { if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { if let Some(prop) = opts.properties.get("capture") { capture = prop.value.to_boolean(); } if let Some(prop) = opts.properties.get("once") { once = prop.value.to_boolean(); } } } _ => {} } } let idx = node_id.index(); let mut listeners = bridge.event_listeners.borrow_mut(); let list = listeners.entry(idx).or_default(); // Don't add duplicate: same type, same callback ref, same capture. let already_exists = list .iter() .any(|l| l.event_type == event_type && l.callback == callback_ref && l.capture == capture); if !already_exists { list.push(EventListener { event_type, callback: callback_ref, capture, once, }); } Ok(Value::Undefined) } fn event_target_remove_listener( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("removeEventListener called on non-node"))?; let event_type = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let callback_ref = match args.get(1) { Some(Value::Function(r)) => *r, _ => return Ok(Value::Undefined), }; let mut capture = false; if let Some(arg) = args.get(2) { match arg { Value::Boolean(b) => capture = *b, Value::Object(opts_ref) => { if let Some(HeapObject::Object(opts)) = ctx.gc.get(*opts_ref) { if let Some(prop) = opts.properties.get("capture") { capture = prop.value.to_boolean(); } } } _ => {} } } let idx = node_id.index(); let mut listeners = bridge.event_listeners.borrow_mut(); if let Some(list) = listeners.get_mut(&idx) { list.retain(|l| { !(l.event_type == event_type && l.callback == callback_ref && l.capture == capture) }); } Ok(Value::Undefined) } /// Native `dispatchEvent` — returns a marker for the VM to handle. fn event_target_dispatch_event( args: &[Value], ctx: &mut NativeContext, ) -> Result<Value, RuntimeError> { let node_id = match &ctx.this { Value::Object(r) => get_node_id(ctx.gc, *r), _ => None, } .ok_or_else(|| RuntimeError::type_error("dispatchEvent called on non-node"))?; let event_ref = match args.first() { Some(Value::Object(r)) => *r, _ => { return Err(RuntimeError::type_error( "dispatchEvent requires an Event argument", )) } }; // Build a marker object for the VM to process. let mut marker = ObjectData::new(); marker.properties.insert( "__event_dispatch__".to_string(), Property::builtin(Value::Boolean(true)), ); marker.properties.insert( "__target_id__".to_string(), Property::builtin(Value::Number(node_id.index() as f64)), ); marker.properties.insert( "__event_ref__".to_string(), Property::builtin(Value::Object(event_ref)), ); Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(marker)))) } /// Register the `Event` constructor and event target methods on the VM. pub fn init_event_system(vm: &mut Vm) { // Register `Event` as a global constructor. let ctor = make_native(&mut vm.gc, "Event", event_constructor); vm.set_global("Event", Value::Function(ctor)); // Add event target methods to the document object. if let Some(Value::Object(doc_ref)) = vm.get_global("document").cloned() { let methods: &[NativeMethod] = &[ ("addEventListener", event_target_add_listener), ("removeEventListener", event_target_remove_listener), ("dispatchEvent", event_target_dispatch_event), ]; for &(name, callback) in methods { let func = make_native(&mut vm.gc, name, callback); set_builtin_prop(&mut vm.gc, doc_ref, name, Value::Function(func)); } // Ensure the document object has __node_id__ set to the root node. if let Some(bridge) = &vm.dom_bridge { let root_idx = bridge.document.borrow().root().index(); set_builtin_prop( &mut vm.gc, doc_ref, NODE_ID_KEY, Value::Number(root_idx as f64), ); // Also cache the document wrapper. bridge.node_wrappers.borrow_mut().insert(root_idx, doc_ref); } } } /// Run the event dispatch algorithm. Called from the VM when it detects an /// event dispatch marker returned by the native `dispatchEvent`. /// /// Returns the result of `!event.defaultPrevented`. pub fn run_event_dispatch(vm: &mut Vm, target_idx: usize, event_ref: GcRef) -> Value { let bridge = match vm.dom_bridge.clone() { Some(b) => b, None => return Value::Boolean(true), }; let target_id = NodeId::from_index(target_idx); // Build propagation path: target -> ... -> root. let path = { let doc = bridge.document.borrow(); let mut path = vec![target_id]; let mut current = target_id; while let Some(parent) = doc.parent(current) { path.push(parent); current = parent; } path.reverse(); // Now root -> ... -> target. path }; // Set event.target to the target wrapper. let target_wrapper = get_or_create_wrapper(target_id, &mut vm.gc, &bridge, vm.object_prototype); set_builtin_prop( &mut vm.gc, event_ref, "target", Value::Object(target_wrapper), ); let target_pos = path.len() - 1; // --- Capture phase (root to target, excluding target) --- set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(1.0)); set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(1.0)); for &node_id in &path[..target_pos] { if is_propagation_stopped(&vm.gc, event_ref) { break; } let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype); set_builtin_prop( &mut vm.gc, event_ref, "currentTarget", Value::Object(wrapper), ); invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, true); } // --- At-target phase --- if !is_propagation_stopped(&vm.gc, event_ref) { set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(2.0)); set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(2.0)); set_builtin_prop( &mut vm.gc, event_ref, "currentTarget", Value::Object(target_wrapper), ); // At target, fire BOTH capture and bubble listeners in registration order. invoke_listeners(vm, &bridge, target_id, event_ref, target_wrapper, false); } // --- Bubble phase (target to root, excluding target) --- let bubbles = match vm.gc.get(event_ref) { Some(HeapObject::Object(data)) => data .properties .get(EVENT_BUBBLES_KEY) .map(|p| p.value.to_boolean()) .unwrap_or(false), _ => false, }; if bubbles && !is_propagation_stopped(&vm.gc, event_ref) { set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(3.0)); set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(3.0)); for &node_id in path[..target_pos].iter().rev() { if is_propagation_stopped(&vm.gc, event_ref) { break; } let wrapper = get_or_create_wrapper(node_id, &mut vm.gc, &bridge, vm.object_prototype); set_builtin_prop( &mut vm.gc, event_ref, "currentTarget", Value::Object(wrapper), ); invoke_listeners(vm, &bridge, node_id, event_ref, wrapper, false); } } // Reset event state. set_builtin_prop(&mut vm.gc, event_ref, "eventPhase", Value::Number(0.0)); set_builtin_prop(&mut vm.gc, event_ref, EVENT_PHASE_KEY, Value::Number(0.0)); set_builtin_prop(&mut vm.gc, event_ref, "currentTarget", Value::Null); // Return !defaultPrevented. let default_prevented = match vm.gc.get(event_ref) { Some(HeapObject::Object(data)) => data .properties .get(EVENT_DEFAULT_PREVENTED_KEY) .map(|p| p.value.to_boolean()) .unwrap_or(false), _ => false, }; Value::Boolean(!default_prevented) } fn is_propagation_stopped(gc: &Gc<HeapObject>, event_ref: GcRef) -> bool { match gc.get(event_ref) { Some(HeapObject::Object(data)) => data .properties .get(EVENT_STOP_PROP_KEY) .map(|p| p.value.to_boolean()) .unwrap_or(false), _ => false, } } fn is_immediate_stopped(gc: &Gc<HeapObject>, event_ref: GcRef) -> bool { match gc.get(event_ref) { Some(HeapObject::Object(data)) => data .properties .get(EVENT_STOP_IMMEDIATE_KEY) .map(|p| p.value.to_boolean()) .unwrap_or(false), _ => false, } } /// Invoke matching listeners on a node for the given event. /// /// When `capture_only` is true (capture phase), only capture listeners fire. /// When false (at-target or bubble), at-target fires all listeners and /// bubble fires only non-capture listeners. We distinguish by the event phase: /// at-target (phase 2) fires all, bubble (phase 3) fires only non-capture. fn invoke_listeners( vm: &mut Vm, bridge: &DomBridge, node_id: NodeId, event_ref: GcRef, current_target_wrapper: GcRef, capture_only: bool, ) { let event_type = match vm.gc.get(event_ref) { Some(HeapObject::Object(data)) => match data.properties.get(EVENT_TYPE_KEY) { Some(Property { value: Value::String(s), .. }) => s.clone(), _ => return, }, _ => return, }; let phase = match vm.gc.get(event_ref) { Some(HeapObject::Object(data)) => match data.properties.get(EVENT_PHASE_KEY) { Some(Property { value: Value::Number(n), .. }) => *n as u8, _ => 0, }, _ => 0, }; let idx = node_id.index(); // Collect matching listeners (snapshot the list to avoid borrow issues). let matching: Vec<(GcRef, bool)> = { let listeners = bridge.event_listeners.borrow(); match listeners.get(&idx) { Some(list) => list .iter() .filter(|l| { if l.event_type != event_type { return false; } if capture_only { // Capture phase: only capture listeners. l.capture } else if phase == 2 { // At-target: all listeners fire. true } else { // Bubble phase: only non-capture listeners. !l.capture } }) .map(|l| (l.callback, l.once)) .collect(), None => return, } }; // Invoke each listener. for (callback_ref, once) in &matching { if is_immediate_stopped(&vm.gc, event_ref) { break; } // Set `this` to currentTarget for the callback. let old_this = vm.get_global("this").cloned(); vm.set_global("this", Value::Object(current_target_wrapper)); let _ = vm.call_function(*callback_ref, &[Value::Object(event_ref)]); // Restore `this`. match old_this { Some(v) => { vm.set_global("this", v); } None => { vm.remove_global("this"); } } // Remove if once. if *once { let mut listeners = bridge.event_listeners.borrow_mut(); if let Some(list) = listeners.get_mut(&idx) { list.retain(|l| { !(l.event_type == event_type && l.callback == *callback_ref && l.once) }); } } } } // ── Tests ─────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::compiler; use crate::parser::Parser; use we_html::parse_html; /// Build a Document from HTML source by parsing it with the HTML parser. fn doc_from_html(html: &str) -> we_dom::Document { parse_html(html) } /// Evaluate JS with a DOM document attached. fn eval_with_doc(html: &str, js: &str) -> Result<Value, RuntimeError> { let doc = doc_from_html(html); let program = Parser::parse(js).expect("parse failed"); let func = compiler::compile(&program).expect("compile failed"); let mut vm = Vm::new(); vm.attach_document(doc); vm.execute(&func) } #[test] fn test_document_is_global() { let result = eval_with_doc("<html><body></body></html>", "typeof document").unwrap(); assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "object"); } #[test] fn test_document_node_type() { let result = eval_with_doc("<html><body></body></html>", "document.nodeType").unwrap(); match result { Value::Number(n) => assert_eq!(n, 9.0), v => panic!("expected 9, got {v:?}"), } } #[test] fn test_document_body() { let result = eval_with_doc("<html><body></body></html>", "document.body.tagName").unwrap(); match result { Value::String(s) => assert_eq!(s, "BODY"), v => panic!("expected 'BODY', got {v:?}"), } } #[test] fn test_document_head() { let result = eval_with_doc( "<html><head></head><body></body></html>", "document.head.tagName", ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "HEAD"), v => panic!("expected 'HEAD', got {v:?}"), } } #[test] fn test_document_document_element() { let result = eval_with_doc( "<html><body></body></html>", "document.documentElement.tagName", ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "HTML"), v => panic!("expected 'HTML', got {v:?}"), } } #[test] fn test_get_element_by_id_found() { let result = eval_with_doc( r#"<html><body><div id="foo">hello</div></body></html>"#, r#"document.getElementById("foo").tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "DIV"), v => panic!("expected 'DIV', got {v:?}"), } } #[test] fn test_get_element_by_id_not_found() { let result = eval_with_doc( "<html><body></body></html>", r#"document.getElementById("nope")"#, ) .unwrap(); assert!(matches!(result, Value::Null)); } #[test] fn test_get_element_by_id_returns_correct_id() { let result = eval_with_doc( r#"<html><body><div id="myid"></div></body></html>"#, r#"document.getElementById("myid").id"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "myid"), v => panic!("expected 'myid', got {v:?}"), } } #[test] fn test_create_element() { let result = eval_with_doc( "<html><body></body></html>", r#"document.createElement("div").tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "DIV"), v => panic!("expected 'DIV', got {v:?}"), } } #[test] fn test_create_text_node() { let result = eval_with_doc( "<html><body></body></html>", r#"document.createTextNode("hello").nodeType"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 3.0), v => panic!("expected 3, got {v:?}"), } } #[test] fn test_element_node_type() { let result = eval_with_doc("<html><body></body></html>", "document.body.nodeType").unwrap(); match result { Value::Number(n) => assert_eq!(n, 1.0), v => panic!("expected 1, got {v:?}"), } } #[test] fn test_query_selector_by_class() { let result = eval_with_doc( r#"<html><body><p class="intro">hi</p></body></html>"#, r#"document.querySelector(".intro").tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "P"), v => panic!("expected 'P', got {v:?}"), } } #[test] fn test_query_selector_not_found() { let result = eval_with_doc( "<html><body></body></html>", r#"document.querySelector(".missing")"#, ) .unwrap(); assert!(matches!(result, Value::Null)); } #[test] fn test_query_selector_all() { let result = eval_with_doc( r#"<html><body><p>a</p><p>b</p><p>c</p></body></html>"#, r#"document.querySelectorAll("p").length"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 3.0), v => panic!("expected 3, got {v:?}"), } } #[test] fn test_get_elements_by_tag_name() { let result = eval_with_doc( r#"<html><body><div>a</div><div>b</div></body></html>"#, r#"document.getElementsByTagName("div").length"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 2.0), v => panic!("expected 2, got {v:?}"), } } #[test] fn test_get_elements_by_class_name() { let result = eval_with_doc( r#"<html><body><span class="x">a</span><span class="x">b</span></body></html>"#, r#"document.getElementsByClassName("x").length"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 2.0), v => panic!("expected 2, got {v:?}"), } } #[test] fn test_wrapper_identity() { let result = eval_with_doc( r#"<html><body><div id="x"></div></body></html>"#, r#"document.getElementById("x") === document.getElementById("x")"#, ) .unwrap(); match result { Value::Boolean(b) => assert!(b, "same node should return same wrapper object"), v => panic!("expected true, got {v:?}"), } } #[test] fn test_document_title() { let result = eval_with_doc( "<html><head><title>Test Page", "document.title", ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "Test Page"), v => panic!("expected 'Test Page', got {v:?}"), } } #[test] fn test_element_class_name() { let result = eval_with_doc( r#"
"#, r#"document.getElementById("d").className"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "foo bar"), v => panic!("expected 'foo bar', got {v:?}"), } } #[test] fn test_missing_element_returns_null() { let result = eval_with_doc( "", r#"document.getElementById("nope") === null"#, ) .unwrap(); match result { Value::Boolean(b) => assert!(b), v => panic!("expected true, got {v:?}"), } } // ── appendChild tests ──────────────────────────────── #[test] fn test_append_child_moves_node() { let result = eval_with_doc( r#"
"#, r#" var parent = document.getElementById("parent"); var child = document.createElement("span"); parent.appendChild(child); parent.hasChildNodes() "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_append_child_returns_child() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var c = document.createElement("span"); var returned = p.appendChild(c); returned === c "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } // ── removeChild tests ──────────────────────────────── #[test] fn test_remove_child() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var c = document.getElementById("c"); p.removeChild(c); p.hasChildNodes() "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(false))); } // ── insertBefore tests ─────────────────────────────── #[test] fn test_insert_before() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var ref = document.getElementById("ref"); var newNode = document.createElement("em"); p.insertBefore(newNode, ref); p.firstChild.tagName "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } #[test] fn test_insert_before_null_appends() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var newNode = document.createElement("em"); p.insertBefore(newNode, null); p.firstChild.tagName "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } // ── replaceChild tests ─────────────────────────────── #[test] fn test_replace_child() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var old = document.getElementById("old"); var newEl = document.createElement("em"); p.replaceChild(newEl, old); p.firstChild.tagName "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } // ── cloneNode tests ────────────────────────────────── #[test] fn test_clone_node_shallow() { let result = eval_with_doc( r#"
child
"#, r#" var orig = document.getElementById("orig"); var clone = orig.cloneNode(false); clone.hasChildNodes() "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(false))); } #[test] fn test_clone_node_deep() { let result = eval_with_doc( r#"
child
"#, r#" var orig = document.getElementById("orig"); var clone = orig.cloneNode(true); clone.hasChildNodes() "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } // ── textContent tests ──────────────────────────────── #[test] fn test_text_content_get() { let result = eval_with_doc( r#"
hello world
"#, r#"document.getElementById("d").textContent"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "hello world"), v => panic!("expected 'hello world', got {v:?}"), } } #[test] fn test_text_content_set() { let result = eval_with_doc( r#"
old
"#, r#" var d = document.getElementById("d"); d.textContent = "new text"; d.textContent "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "new text"), v => panic!("expected 'new text', got {v:?}"), } } // ── innerHTML tests ────────────────────────────────── #[test] fn test_inner_html_get() { let result = eval_with_doc( r#"
hi
"#, r#"document.getElementById("d").innerHTML"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "hi"), v => panic!("expected 'hi', got {v:?}"), } } #[test] fn test_inner_html_set() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.innerHTML = "new"; d.firstChild.tagName "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } // ── outerHTML test ─────────────────────────────────── #[test] fn test_outer_html() { let result = eval_with_doc( r#"
hi
"#, r#"document.getElementById("d").outerHTML"#, ) .unwrap(); match result { Value::String(s) => assert!( s.contains("") && s.contains("hi"), "unexpected outerHTML: {s}" ), v => panic!("expected string, got {v:?}"), } } // ── getAttribute / setAttribute tests ──────────────── #[test] fn test_get_attribute() { let result = eval_with_doc( r#"x"#, r#"document.getElementById("link").getAttribute("href")"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "https://example.com"), v => panic!("expected URL, got {v:?}"), } } #[test] fn test_get_attribute_missing() { let result = eval_with_doc( r#"
"#, r#"document.getElementById("d").getAttribute("nope") === null"#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_set_attribute() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.setAttribute("data-x", "123"); d.getAttribute("data-x") "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "123"), v => panic!("expected '123', got {v:?}"), } } #[test] fn test_has_attribute() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.hasAttribute("class") && !d.hasAttribute("nope") "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_remove_attribute() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.removeAttribute("class"); d.hasAttribute("class") "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(false))); } // ── Navigation property tests ──────────────────────── #[test] fn test_parent_node() { let result = eval_with_doc( r#"
"#, r#"document.getElementById("child").parentNode.tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "BODY"), v => panic!("expected 'BODY', got {v:?}"), } } #[test] fn test_child_nodes() { let result = eval_with_doc( r#"
ab
"#, r#"document.getElementById("p").childNodes.length"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 2.0), v => panic!("expected 2, got {v:?}"), } } #[test] fn test_children_element_only() { let result = eval_with_doc( r#"
atextb
"#, r#"document.getElementById("p").children.length"#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 2.0), v => panic!("expected 2, got {v:?}"), } } #[test] fn test_first_child() { let result = eval_with_doc( r#"
ab
"#, r#"document.getElementById("p").firstChild.tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "SPAN"), v => panic!("expected 'SPAN', got {v:?}"), } } #[test] fn test_last_child() { let result = eval_with_doc( r#"
ab
"#, r#"document.getElementById("p").lastChild.tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } #[test] fn test_next_sibling() { let result = eval_with_doc( r#"12"#, r#"document.getElementById("a").nextSibling.tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "EM"), v => panic!("expected 'EM', got {v:?}"), } } #[test] fn test_previous_sibling() { let result = eval_with_doc( r#"12"#, r#"document.getElementById("b").previousSibling.tagName"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "SPAN"), v => panic!("expected 'SPAN', got {v:?}"), } } #[test] fn test_null_navigation() { let result = eval_with_doc( r#"
"#, r#"document.getElementById("only").nextSibling === null"#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } // ── style property tests ───────────────────────────── #[test] fn test_style_set_and_get() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.style.color = "red"; d.style.color "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "red"), v => panic!("expected 'red', got {v:?}"), } } #[test] fn test_style_read_existing() { let result = eval_with_doc( r#"
"#, r#"document.getElementById("d").style.color"#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "blue"), v => panic!("expected 'blue', got {v:?}"), } } // ── classList tests ────────────────────────────────── #[test] fn test_class_list_add() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.classList.add("foo"); d.getAttribute("class") "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "foo"), v => panic!("expected 'foo', got {v:?}"), } } #[test] fn test_class_list_remove() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.classList.remove("foo"); d.getAttribute("class") "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "bar"), v => panic!("expected 'bar', got {v:?}"), } } #[test] fn test_class_list_toggle() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); var removed = d.classList.toggle("foo"); var added = d.classList.toggle("bar"); !removed && added "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_class_list_contains() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.classList.contains("foo") && !d.classList.contains("baz") "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } // ── setAttribute syncs id/className ────────────────── #[test] fn test_set_attribute_syncs_id() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.setAttribute("id", "new_id"); d.id "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "new_id"), v => panic!("expected 'new_id', got {v:?}"), } } // ── id and className setters ───────────────────────── #[test] fn test_set_id_syncs_attribute() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.id = "new_id"; d.getAttribute("id") "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "new_id"), v => panic!("expected 'new_id', got {v:?}"), } } #[test] fn test_set_class_name_syncs_attribute() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.className = "a b"; d.getAttribute("class") "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "a b"), v => panic!("expected 'a b', got {v:?}"), } } // ── Navigation after DOM modification ──────────────── #[test] fn test_navigation_after_append() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var a = document.createElement("span"); var b = document.createElement("em"); p.appendChild(a); p.appendChild(b); p.firstChild.tagName + "," + p.lastChild.tagName "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "SPAN,EM"), v => panic!("expected 'SPAN,EM', got {v:?}"), } } // ── Removed nodes are detached ─────────────────────── #[test] fn test_removed_node_detached() { let result = eval_with_doc( r#"
"#, r#" var p = document.getElementById("p"); var c = document.getElementById("c"); p.removeChild(c); c.parentNode === null "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } // ── Event system tests ────────────────────────────────── #[test] fn test_event_constructor() { let result = eval_with_doc( "", r#" var e = new Event("click"); e.type "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "click"), v => panic!("expected 'click', got {v:?}"), } } #[test] fn test_event_constructor_options() { let result = eval_with_doc( "", r#" var e = new Event("click", { bubbles: true, cancelable: true }); e.bubbles + "," + e.cancelable "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "true,true"), v => panic!("expected 'true,true', got {v:?}"), } } #[test] fn test_event_default_properties() { let result = eval_with_doc( "", r#" var e = new Event("test"); e.bubbles + "," + e.cancelable + "," + e.defaultPrevented + "," + e.eventPhase "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "false,false,false,0"), v => panic!("expected 'false,false,false,0', got {v:?}"), } } #[test] fn test_add_event_listener_and_dispatch() { let result = eval_with_doc( r#"
"#, r#" var called = false; var d = document.getElementById("d"); d.addEventListener("click", function(e) { called = true; }); d.dispatchEvent(new Event("click")); called "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_event_handler_receives_event_object() { let result = eval_with_doc( r#"
"#, r#" var eventType = ""; var d = document.getElementById("d"); d.addEventListener("myevent", function(e) { eventType = e.type; }); d.dispatchEvent(new Event("myevent")); eventType "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "myevent"), v => panic!("expected 'myevent', got {v:?}"), } } #[test] fn test_event_target_set_correctly() { let result = eval_with_doc( r#"
"#, r#" var targetTag = ""; var d = document.getElementById("d"); d.addEventListener("click", function(e) { targetTag = e.target.tagName; }); d.dispatchEvent(new Event("click")); targetTag "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "DIV"), v => panic!("expected 'DIV', got {v:?}"), } } #[test] fn test_event_bubbling() { let result = eval_with_doc( r#"
"#, r#" var order = ""; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("click", function(e) { order = order + "parent"; }); child.addEventListener("click", function(e) { order = order + "child,"; }); child.dispatchEvent(new Event("click", { bubbles: true })); order "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "child,parent"), v => panic!("expected 'child,parent', got {v:?}"), } } #[test] fn test_event_no_bubbling_by_default() { let result = eval_with_doc( r#"
"#, r#" var parentCalled = false; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("test", function(e) { parentCalled = true; }); child.dispatchEvent(new Event("test")); parentCalled "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(false))); } #[test] fn test_event_capture_phase() { let result = eval_with_doc( r#"
"#, r#" var order = ""; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("click", function(e) { order = order + "capture,"; }, true); parent.addEventListener("click", function(e) { order = order + "bubble"; }); child.addEventListener("click", function(e) { order = order + "target,"; }); child.dispatchEvent(new Event("click", { bubbles: true })); order "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "capture,target,bubble"), v => panic!("expected 'capture,target,bubble', got {v:?}"), } } #[test] fn test_stop_propagation() { let result = eval_with_doc( r#"
"#, r#" var parentCalled = false; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("click", function(e) { parentCalled = true; }); child.addEventListener("click", function(e) { e.stopPropagation(); }); child.dispatchEvent(new Event("click", { bubbles: true })); parentCalled "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(false))); } #[test] fn test_stop_immediate_propagation() { let result = eval_with_doc( r#"
"#, r#" var count = 0; var d = document.getElementById("d"); d.addEventListener("click", function(e) { count = count + 1; e.stopImmediatePropagation(); }); d.addEventListener("click", function(e) { count = count + 1; }); d.dispatchEvent(new Event("click")); count "#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 1.0), v => panic!("expected 1, got {v:?}"), } } #[test] fn test_prevent_default() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.addEventListener("click", function(e) { e.preventDefault(); }); var result = d.dispatchEvent(new Event("click", { cancelable: true })); result "#, ) .unwrap(); // dispatchEvent returns false when preventDefault was called. assert!(matches!(result, Value::Boolean(false))); } #[test] fn test_prevent_default_not_cancelable() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.addEventListener("click", function(e) { e.preventDefault(); }); var result = d.dispatchEvent(new Event("click")); result "#, ) .unwrap(); // Non-cancelable: preventDefault has no effect, returns true. assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_remove_event_listener() { let result = eval_with_doc( r#"
"#, r#" var count = 0; var d = document.getElementById("d"); var handler = function(e) { count = count + 1; }; d.addEventListener("click", handler); d.dispatchEvent(new Event("click")); d.removeEventListener("click", handler); d.dispatchEvent(new Event("click")); count "#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 1.0), v => panic!("expected 1, got {v:?}"), } } #[test] fn test_once_option() { let result = eval_with_doc( r#"
"#, r#" var count = 0; var d = document.getElementById("d"); d.addEventListener("click", function(e) { count = count + 1; }, { once: true }); d.dispatchEvent(new Event("click")); d.dispatchEvent(new Event("click")); count "#, ) .unwrap(); match result { Value::Number(n) => assert_eq!(n, 1.0), v => panic!("expected 1, got {v:?}"), } } #[test] fn test_dispatch_event_returns_true_normally() { let result = eval_with_doc( r#"
"#, r#" var d = document.getElementById("d"); d.dispatchEvent(new Event("click")) "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_event_current_target() { let result = eval_with_doc( r#"
"#, r#" var currentTag = ""; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("click", function(e) { currentTag = e.currentTarget.tagName; }); child.dispatchEvent(new Event("click", { bubbles: true })); currentTag "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "DIV"), v => panic!("expected 'DIV', got {v:?}"), } } #[test] fn test_multiple_listeners_same_type() { let result = eval_with_doc( r#"
"#, r#" var order = ""; var d = document.getElementById("d"); d.addEventListener("click", function(e) { order = order + "a,"; }); d.addEventListener("click", function(e) { order = order + "b"; }); d.dispatchEvent(new Event("click")); order "#, ) .unwrap(); match result { Value::String(s) => assert_eq!(s, "a,b"), v => panic!("expected 'a,b', got {v:?}"), } } #[test] fn test_capture_option_object() { let result = eval_with_doc( r#"
"#, r#" var capturePhase = false; var parent = document.getElementById("parent"); var child = document.getElementById("child"); parent.addEventListener("click", function(e) { capturePhase = (e.eventPhase === 1); }, { capture: true }); child.dispatchEvent(new Event("click", { bubbles: true })); capturePhase "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } #[test] fn test_document_add_event_listener() { let result = eval_with_doc( r#"
"#, r#" var docCalled = false; document.addEventListener("click", function(e) { docCalled = true; }); var d = document.getElementById("d"); d.dispatchEvent(new Event("click", { bubbles: true })); docCalled "#, ) .unwrap(); assert!(matches!(result, Value::Boolean(true))); } }