diff --git a/crates/obscura-dom/src/lib.rs b/crates/obscura-dom/src/lib.rs index 6d3d6fc..6ad84be 100644 --- a/crates/obscura-dom/src/lib.rs +++ b/crates/obscura-dom/src/lib.rs @@ -7,4 +7,4 @@ pub mod selector; pub mod serialize; pub use tree::{Attribute, DomTree, Node, NodeData, NodeId}; -pub use tree_sink::{parse_html, parse_fragment}; +pub use tree_sink::{parse_fragment, parse_fragment_with_context, parse_html}; diff --git a/crates/obscura-dom/src/tree_sink.rs b/crates/obscura-dom/src/tree_sink.rs index e116b5a..8720fd5 100644 --- a/crates/obscura-dom/src/tree_sink.rs +++ b/crates/obscura-dom/src/tree_sink.rs @@ -246,10 +246,14 @@ pub fn parse_html(html: &str) -> DomTree { } pub fn parse_fragment(html: &str) -> DomTree { - use html5ever::tendril::TendrilSink; - use html5ever::{parse_fragment, ParseOpts, QualName}; - let context_name = QualName::new(None, ns!(html), local_name!("body")); + parse_fragment_with_context(html, context_name) +} + +pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree { + use html5ever::tendril::TendrilSink; + use html5ever::{parse_fragment, ParseOpts}; + let tree = DomTree::new(); parse_fragment(tree, ParseOpts::default(), context_name, vec![]) .from_utf8() diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 423bc40..9422a22 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -24,7 +24,7 @@ '_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase', '_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth', '_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber', - '_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl', + '_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_elementClassFor', '_wrap', '_wrapEl', '_resolveUrl', '_registerIframe', '_base64ToUint8Array', '_bodyToUint8Array', '_arrayBufferFromBytes', '_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp', @@ -1329,6 +1329,23 @@ function _isSubmitButton(el) { return false; } +// Parse an HTML string into detached nodes using the actual insertion element +// as html5ever's fragment context. This preserves table/select parsing rules, +// comments, text-node order, and foreign-content namespaces without a wrap map. +function _parseHTMLFragment(html, context) { + html = String(html == null ? '' : html); + const ns = context && context.nodeType === 1 ? context.namespaceURI : null; + const tag = context && context.nodeType === 1 ? context.localName : 'body'; + const tmp = ns && ns !== 'http://www.w3.org/1999/xhtml' + ? document.createElementNS(ns, tag) + : document.createElement(tag); + tmp.innerHTML = html; + const out = []; + let child; + while ((child = tmp.firstChild)) out.push(tmp.removeChild(child)); + return out; +} + class Element extends Node { constructor(nid) { super(nid); @@ -1583,20 +1600,33 @@ class Element extends Node { return null; } insertAdjacentHTML(position, html) { + // Position is matched ASCII-case-insensitively; an unknown value throws + // SyntaxError (both were silent no-ops before). Sibling insertions parse + // against the parent's context, child insertions against this element, so + // table/select fragments keep the right parsing context (_parseHTMLFragment). + const pos = String(position).toLowerCase(); const parent = this.parentNode; - switch (position) { + const context = (pos === 'beforebegin' || pos === 'afterend') ? parent : this; + switch (pos) { case 'beforebegin': - if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], this); } + if (parent) for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, this); break; - case 'afterbegin': - { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const first = this.firstChild; for (let i = children.length - 1; i >= 0; i--) this.insertBefore(children[i], first); } + case 'afterbegin': { + const first = this.firstChild; + for (const n of _parseHTMLFragment(html, context)) this.insertBefore(n, first); break; + } case 'beforeend': - { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; for (let i = 0; i < children.length; i++) this.appendChild(children[i]); } + for (const n of _parseHTMLFragment(html, context)) this.appendChild(n); break; case 'afterend': - if (parent) { const tmp = document.createElement('div'); tmp.innerHTML = html; const children = tmp.childNodes; const next = this.nextSibling; for (let i = 0; i < children.length; i++) parent.insertBefore(children[i], next); } + if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, next); } break; + default: + throw new DOMException( + "Failed to execute 'insertAdjacentHTML' on 'Element': The value provided ('" + position + "') is not one of 'beforeBegin', 'afterBegin', 'beforeEnd', or 'afterEnd'.", + "SyntaxError" + ); } } // Like insertAdjacentHTML but inserts a Text node instead of parsing markup, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 64aad05..37e5cc7 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -391,7 +391,16 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str dom.detach(child); } if !arg2.is_empty() { - let fragment = obscura_dom::parse_fragment(&arg2); + let context_name = dom + .with_node(target, |node| match &node.data { + NodeData::Element { name, .. } => Some(name.clone()), + _ => None, + }) + .flatten(); + let fragment = match context_name { + Some(name) => obscura_dom::parse_fragment_with_context(&arg2, name), + None => obscura_dom::parse_fragment(&arg2), + }; let import_root = fragment.find_body_or_root(); dom.import_children_from(target, &fragment, import_root); } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 541b336..aa84dde 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1549,6 +1549,61 @@ mod tests { assert_eq!(out, serde_json::json!("1|OPTION|choice")); } + #[test] + fn insert_adjacent_html_parses_table_fragments() { + let mut rt = setup_runtime( + r#"
"#, + ); + let out = rt + .evaluate("(function(){var tb=document.getElementById('tb'); tb.insertAdjacentHTML('beforeend','12'); var tr=tb.firstElementChild; return tr ? (tr.tagName+':'+tr.children.length) : 'NULL';})()") + .unwrap(); + assert_eq!(out, serde_json::json!("TR:2")); + } + + #[test] + fn insert_adjacent_html_position_is_case_insensitive() { + let mut rt = setup_runtime(r#"
base
"#); + let out = rt + .evaluate("(function(){var h=document.getElementById('host'); h.insertAdjacentHTML('BeforeEnd','x'); return h.lastElementChild ? h.lastElementChild.tagName : 'NULL';})()") + .unwrap(); + assert_eq!(out, serde_json::json!("B")); + } + + #[test] + fn insert_adjacent_html_rejects_invalid_position() { + let mut rt = setup_runtime(r#"
"#); + let out = rt + .evaluate("(function(){var h=document.getElementById('host'); try { h.insertAdjacentHTML('nope','x'); return 'no-throw'; } catch(e){ return e.name; }})()") + .unwrap(); + assert_eq!(out, serde_json::json!("SyntaxError")); + } + + #[test] + fn insert_adjacent_html_keeps_leading_comments_in_table_contexts() { + let mut rt = setup_runtime( + r#"
"#, + ); + let out = rt + .evaluate( + "(function(){var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','v');var row=document.getElementById('row');row.insertAdjacentHTML('beforeend','x');return Array.from(tb.childNodes).map(function(n){return n.nodeName}).join('|')+';'+Array.from(row.childNodes).map(function(n){return n.nodeName}).join('|');})()", + ) + .unwrap(); + assert_eq!(out, serde_json::json!("TR|#comment|TR;#comment|TD")); + } + + #[test] + fn insert_adjacent_html_uses_the_insertion_element_as_context() { + let mut rt = setup_runtime( + r#"
"#, + ); + let out = rt + .evaluate( + "(function(){var d=document.getElementById('d');d.insertAdjacentHTML('beforeend','v');var table=document.getElementById('table');table.insertAdjacentHTML('beforeend','x');var tb=document.getElementById('tb');tb.insertAdjacentHTML('beforeend','ytail');return d.firstChild.nodeName+':'+d.textContent+';'+table.lastElementChild.tagName+';'+Array.from(tb.childNodes).map(function(n){return n.nodeName+(n.data?':'+n.data:'')}).join('|');})()", + ) + .unwrap(); + assert_eq!(out, serde_json::json!("#text:v;TBODY;TR|#text:tail")); + } + #[test] fn test_document_title() { let mut rt = setup_runtime("Test");