diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 3ef371a..9ec7e83 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', @@ -1177,6 +1177,47 @@ 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) { + 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 out = []; + let child; + while ((child = container.firstChild)) out.push(container.removeChild(child)); + return out; +} + class Element extends Node { constructor(nid) { super(nid); @@ -1429,20 +1470,35 @@ 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 contextTag = (pos === 'beforebegin' || pos === 'afterend') + ? (parent && parent.nodeType === 1 ? parent.tagName : 'body') + : this.tagName; + 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, contextTag)) 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, contextTag)) 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, contextTag)) 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, contextTag)) 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/runtime.rs b/crates/obscura-js/src/runtime.rs index fdfeb4d..7be59f6 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1317,6 +1317,35 @@ mod tests { rt } + #[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 test_document_title() { let mut rt = setup_runtime("Test");