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 674732f..4909579 100644
--- a/crates/obscura-dom/src/tree_sink.rs
+++ b/crates/obscura-dom/src/tree_sink.rs
@@ -243,10 +243,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 00b3b27..1baa768 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -1227,44 +1227,20 @@ function _isSubmitButton(el) {
return false;
}
-// Ancestor chains required to parse table/select children, which the fragment
-// parser drops when parsed loosely (it uses a fixed
context). Each entry
-// is [openTags, closeTags, depth] — depth is how many lastChild hops reach the
-// container holding the parsed rows/cells/options. Classic jQuery wrapMap.
-const _wrapMap = {
- option: ['', 1],
- optgroup: ['', 1],
- tr: ['', 2],
- td: ['', 3],
- th: ['', 3],
- tbody: ['', 1],
- thead: ['', 1],
- tfoot: ['', 1],
- caption: ['', 1],
- colgroup: ['', 1],
- col: ['', 2],
-};
-// Parse an HTML string into an array of detached nodes. Table/select fragments
-// are wrapped in the required ancestor chain and dug back out so their children
-// survive; the wrapper is chosen from the fragment's own leading tag, falling
-// back to the insertion context element's tag.
-function _parseHTMLFragment(html, contextTag) {
+// 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 m = /^\s*<\s*([a-zA-Z][a-zA-Z0-9]*)/.exec(html);
- const lead = m ? m[1].toLowerCase() : '';
- const key = _wrapMap[lead] ? lead : (contextTag ? String(contextTag).toLowerCase() : '');
- const wrap = _wrapMap[key];
- const tmp = document.createElement('div');
- let container = tmp;
- if (wrap) {
- tmp.innerHTML = wrap[0] + html + wrap[1];
- for (let d = 0; d < wrap[2]; d++) container = container.lastChild || container;
- } else {
- tmp.innerHTML = 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 = container.firstChild)) out.push(container.removeChild(child));
+ while ((child = tmp.firstChild)) out.push(tmp.removeChild(child));
return out;
}
@@ -1526,23 +1502,21 @@ class Element extends Node {
// table/select fragments keep the right parsing context (_parseHTMLFragment).
const pos = String(position).toLowerCase();
const parent = this.parentNode;
- const contextTag = (pos === 'beforebegin' || pos === 'afterend')
- ? (parent && parent.nodeType === 1 ? parent.tagName : 'body')
- : this.tagName;
+ const context = (pos === 'beforebegin' || pos === 'afterend') ? parent : this;
switch (pos) {
case 'beforebegin':
- if (parent) for (const n of _parseHTMLFragment(html, contextTag)) parent.insertBefore(n, this);
+ if (parent) for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, this);
break;
case 'afterbegin': {
const first = this.firstChild;
- for (const n of _parseHTMLFragment(html, contextTag)) this.insertBefore(n, first);
+ for (const n of _parseHTMLFragment(html, context)) this.insertBefore(n, first);
break;
}
case 'beforeend':
- for (const n of _parseHTMLFragment(html, contextTag)) this.appendChild(n);
+ for (const n of _parseHTMLFragment(html, context)) this.appendChild(n);
break;
case 'afterend':
- if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, contextTag)) parent.insertBefore(n, next); }
+ if (parent) { const next = this.nextSibling; for (const n of _parseHTMLFragment(html, context)) parent.insertBefore(n, next); }
break;
default:
throw new DOMException(
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 2e022c0..8f6dc5c 100644
--- a/crates/obscura-js/src/runtime.rs
+++ b/crates/obscura-js/src/runtime.rs
@@ -1346,6 +1346,32 @@ mod tests {
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','| y |
tail');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");