Merge pull request #2824 from lightpanda-io/script_innerHTML

fix: Fix innerHTML parsing based on the target
This commit is contained in:
Karl Seguin
2026-06-27 12:08:04 +08:00
committed by GitHub
5 changed files with 80 additions and 9 deletions

View File

@@ -320,15 +320,22 @@ fn shouldStripElement(el: *Node.Element, opts: Opts, frame: *Frame) bool {
}
fn shouldEscapeText(node_: ?*Node) bool {
// Raw text elements serialize their text content literally rather than
// HTML-escaping it
const node = node_ orelse return true;
if (node.is(Node.Element.Html.Script) != null) {
return false;
}
// When scripting is enabled, <noscript> is a raw text element per the HTML spec
// (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments).
// Its text content must not be HTML-escaped during serialization.
if (node.is(Node.Element.Html.Generic)) |generic| {
if (generic._tag == .noscript) return false;
const element = node.is(Node.Element) orelse return true;
const html_element = node.is(Node.Element.Html) orelse return true;
switch (html_element._type) {
.style, .script, .iframe => return false,
else => {
const tag = element.getTagNameLower();
inline for (.{ "xmp", "noembed", "noframes", "plaintext", "noscript" }) |raw_text_tag| {
if (std.mem.eql(u8, tag, raw_text_tag)) {
return false;
}
}
},
}
return true;
}

View File

@@ -259,9 +259,16 @@ pub fn parseXML(self: *Parser, xml: []const u8) void {
}
pub fn parseFragment(self: *Parser, html: []const u8) void {
const context_name: []const u8 = if (self.container.node.is(Element)) |el|
el.getLocalName()
else
"";
h5e.html5ever_parse_fragment(
html.ptr,
html.len,
context_name.ptr,
context_name.len,
&self.container,
self,
createElementCallback,

View File

@@ -70,6 +70,8 @@ pub extern "c" fn html5ever_parse_document_with_encoding(
pub extern "c" fn html5ever_parse_fragment(
html: [*c]const u8,
len: usize,
context_name: [*c]const u8,
context_name_len: usize,
doc: *anyopaque,
ctx: *anyopaque,
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,

View File

@@ -201,3 +201,42 @@
d1.innerHTML = '<span style="visibility:hidden">X</span>Y';
testing.expectEqual("XY", d1.innerText);
</script>
<script id=rawTextContext>
// Setting innerHTML on a raw-text element (script/style/textarea/title) must
// parse the content in the matching raw-text tokenizer state. In particular a
// '<' followed by a letter is plain text, NOT the start of a tag — otherwise
// the body is truncated at the first '<' (e.g. `for(;l<localStorage.length;)`
// would be swallowed, leaving a half-statement that fails to compile).
const script = document.createElement('script');
const body = 'window.__x=0; for(let l=0;l<localStorage.length+3;){window.__x=l;l++;}';
script.innerHTML = body;
testing.expectEqual(body, script.innerHTML);
testing.expectEqual(1, script.childNodes.length);
testing.expectEqual(body, script.textContent);
const style = document.createElement('style');
style.innerHTML = 'a<b{color:red}';
testing.expectEqual('a<b{color:red}', style.innerHTML);
testing.expectEqual(1, style.childNodes.length);
// textarea / title are RCDATA: '<' + letter stays text (not a tag) and
// character references are decoded on parse. On serialization the content is
// re-escaped (RCDATA elements are NOT raw-text-serialized), so the round-trip
// shows '<' as &lt; and the decoded '&' re-escaped as &amp;.
const textarea = document.createElement('textarea');
textarea.innerHTML = 'x < y &amp; z';
testing.expectEqual('x < y & z', textarea.value);
testing.expectEqual('x &lt; y &amp; z', textarea.innerHTML);
const title = document.createElement('title');
title.innerHTML = '1 < 2 < three';
testing.expectEqual(1, title.childNodes.length);
testing.expectEqual('1 < 2 < three', title.textContent);
testing.expectEqual('1 &lt; 2 &lt; three', title.innerHTML);
// A normal element stays in the data state: '<b>' is a real child element.
d1.innerHTML = 'a<b>bold</b>';
testing.expectEqual('a<b>bold</b>', d1.innerHTML);
testing.expectEqual('B', d1.lastChild.tagName);
</script>

View File

@@ -461,6 +461,8 @@ pub extern "C" fn encoding_max_encode_buffer_length(
pub extern "C" fn html5ever_parse_fragment(
html: *mut c_uchar,
len: usize,
context_name: *const c_uchar,
context_name_len: usize,
document: Ref,
ctx: Ref,
create_element_callback: CreateElementCallback,
@@ -510,10 +512,24 @@ pub extern "C" fn html5ever_parse_fragment(
};
let bytes = unsafe { std::slice::from_raw_parts(html, len) };
// The initial state of the parser is dertmined by the context element.
// E.g., parsing the content of a script tag behaves differently than
// parsing the content of the body.
let context_local = if context_name.is_null() || context_name_len == 0 {
LocalName::from("body")
} else {
let name_bytes = unsafe { std::slice::from_raw_parts(context_name, context_name_len) };
match std::str::from_utf8(name_bytes) {
Ok(name) => LocalName::from(name),
Err(_) => LocalName::from("body"),
}
};
parse_fragment(
sink,
Default::default(),
QualName::new(None, ns!(html), LocalName::from("body")),
QualName::new(None, ns!(html), context_local),
vec![], // attributes
false, // context_element_allows_scripting
)