Merge pull request #1354 from lightpanda-io/node_document

Node document
This commit is contained in:
Pierre Tachoire
2026-01-12 15:14:57 +01:00
committed by GitHub
6 changed files with 579 additions and 16 deletions

View File

@@ -201,8 +201,8 @@ cdataClassName<!DOCTYPE html>
root.appendChild(cdata);
root.appendChild(elem2);
testing.expectEqual('LAST', cdata.nextElementSibling.tagName);
testing.expectEqual('FIRST', cdata.previousElementSibling.tagName);
testing.expectEqual('last', cdata.nextElementSibling.tagName);
testing.expectEqual('first', cdata.previousElementSibling.tagName);
}
</script>

View File

@@ -0,0 +1,344 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<head>
<title>document.replaceChildren Tests</title>
</head>
<body>
<div id="test">Original content</div>
</body>
<script id=error_multiple_elements>
{
// Test that we cannot have more than one Element child
const doc = new Document();
const div1 = doc.createElement('div');
const div2 = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(div1, div2);
});
}
</script>
<script id=error_multiple_elements_via_fragment>
{
// Test that we cannot have more than one Element child via DocumentFragment
const doc = new Document();
const fragment = doc.createDocumentFragment();
fragment.appendChild(doc.createElement('div'));
fragment.appendChild(doc.createElement('span'));
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(fragment);
});
}
</script>
<script id=error_multiple_doctypes>
{
// Test that we cannot have more than one DocumentType child
const doc = new Document();
const doctype1 = doc.implementation.createDocumentType('html', '', '');
const doctype2 = doc.implementation.createDocumentType('html', '', '');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(doctype1, doctype2);
});
}
</script>
<script id=error_text_node>
{
// Test that we cannot insert Text nodes directly into Document
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren('Just text');
});
}
</script>
<script id=error_text_with_element>
{
// Test that we cannot insert Text nodes even with valid Element
const doc = new Document();
const html = doc.createElement('html');
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren('Text 1', html, 'Text 2');
});
}
</script>
<script id=error_append_multiple_elements>
{
// Test that append also validates
const doc = new Document();
doc.append(doc.createElement('html'));
const div = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.append(div);
});
}
</script>
<script id=error_prepend_multiple_elements>
{
// Test that prepend also validates
const doc = new Document();
doc.prepend(doc.createElement('html'));
const div = doc.createElement('div');
testing.expectError('HierarchyRequest', () => {
doc.prepend(div);
});
}
</script>
<script id=error_append_text>
{
// Test that append rejects text nodes
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.append('text');
});
}
</script>
<script id=error_prepend_text>
{
// Test that prepend rejects text nodes
const doc = new Document();
testing.expectError('HierarchyRequest', () => {
doc.prepend('text');
});
}
</script>
<script id=replace_with_single_element>
{
const doc = new Document();
const html = doc.createElement('html');
html.id = 'replaced';
html.textContent = 'New content';
doc.replaceChildren(html);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual(html, doc.firstChild);
testing.expectEqual('replaced', doc.firstChild.id);
}
</script>
<script id=replace_with_comments>
{
const doc = new Document();
const comment1 = doc.createComment('Comment 1');
const html = doc.createElement('html');
const comment2 = doc.createComment('Comment 2');
doc.replaceChildren(comment1, html, comment2);
testing.expectEqual(3, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('Comment 1', doc.firstChild.textContent);
testing.expectEqual('html', doc.childNodes[1].nodeName);
testing.expectEqual('#comment', doc.lastChild.nodeName);
testing.expectEqual('Comment 2', doc.lastChild.textContent);
}
</script>
<script id=replace_with_empty>
{
const doc = new Document();
// First add some content
const div = doc.createElement('div');
doc.replaceChildren(div);
testing.expectEqual(1, doc.childNodes.length);
// Now replace with nothing
doc.replaceChildren();
testing.expectEqual(0, doc.childNodes.length);
testing.expectEqual(null, doc.firstChild);
testing.expectEqual(null, doc.lastChild);
}
</script>
<script id=replace_removes_old_children>
{
const doc = new Document();
const comment1 = doc.createComment('old');
doc.replaceChildren(comment1);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual(doc, comment1.parentNode);
const html = doc.createElement('html');
html.id = 'new';
doc.replaceChildren(html);
// Old child should be removed
testing.expectEqual(null, comment1.parentNode);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('new', doc.firstChild.id);
}
</script>
<script id=replace_with_document_fragment_valid>
{
const doc = new Document();
const fragment = doc.createDocumentFragment();
const html = doc.createElement('html');
const comment = doc.createComment('comment');
fragment.appendChild(comment);
fragment.appendChild(html);
doc.replaceChildren(fragment);
// Fragment contents should be moved
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
// Fragment should be empty now
testing.expectEqual(0, fragment.childNodes.length);
}
</script>
<script id=replace_maintains_child_order>
{
const doc = new Document();
const nodes = [];
// Document can have: comment, processing instruction, doctype, element
nodes.push(doc.createComment('comment'));
nodes.push(doc.createElement('html'));
doc.replaceChildren(...nodes);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.childNodes[0].nodeName);
testing.expectEqual('html', doc.childNodes[1].nodeName);
}
</script>
<script id=replace_with_nested_structure>
{
const doc = new Document();
const outer = doc.createElement('html');
outer.id = 'outer';
const middle = doc.createElement('body');
middle.id = 'middle';
const inner = doc.createElement('span');
inner.id = 'inner';
inner.textContent = 'Nested';
middle.appendChild(inner);
outer.appendChild(middle);
doc.replaceChildren(outer);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('outer', doc.firstChild.id);
const foundInner = doc.getElementById('inner');
testing.expectEqual(inner, foundInner);
testing.expectEqual('Nested', foundInner.textContent);
}
</script>
<script id=consecutive_replaces>
{
const doc = new Document();
const html1 = doc.createElement('html');
html1.id = 'first-replace';
doc.replaceChildren(html1);
testing.expectEqual('first-replace', doc.firstChild.id);
// Replace element with comments
const comment = doc.createComment('in between');
doc.replaceChildren(comment);
testing.expectEqual(1, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
// Replace comments with new element
const html2 = doc.createElement('html');
html2.id = 'second-replace';
doc.replaceChildren(html2);
testing.expectEqual('second-replace', doc.firstChild.id);
testing.expectEqual(1, doc.childNodes.length);
// First element should no longer be in document
testing.expectEqual(null, html1.parentNode);
testing.expectEqual(null, comment.parentNode);
}
</script>
<script id=replace_with_comments_only>
{
const doc = new Document();
const comment1 = doc.createComment('First');
const comment2 = doc.createComment('Second');
doc.replaceChildren(comment1, comment2);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('First', doc.firstChild.textContent);
testing.expectEqual('#comment', doc.lastChild.nodeName);
testing.expectEqual('Second', doc.lastChild.textContent);
}
</script>
<script id=error_fragment_with_text>
{
// DocumentFragment with text should fail when inserted into Document
const doc = new Document();
const fragment = doc.createDocumentFragment();
fragment.appendChild(doc.createTextNode('text'));
fragment.appendChild(doc.createElement('html'));
testing.expectError('HierarchyRequest', () => {
doc.replaceChildren(fragment);
});
}
</script>
<script id=append_valid_nodes>
{
const doc = new Document();
const comment = doc.createComment('test');
const html = doc.createElement('html');
doc.append(comment);
testing.expectEqual(1, doc.childNodes.length);
doc.append(html);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
}
</script>
<script id=prepend_valid_nodes>
{
const doc = new Document();
const html = doc.createElement('html');
const comment = doc.createComment('test');
doc.prepend(html);
testing.expectEqual(1, doc.childNodes.length);
doc.prepend(comment);
testing.expectEqual(2, doc.childNodes.length);
testing.expectEqual('#comment', doc.firstChild.nodeName);
testing.expectEqual('html', doc.lastChild.nodeName);
}
</script>

View File

@@ -36,7 +36,7 @@
function expectError(expected, fn) {
withError((err) => {
expectEqual(expected, err.toString());
expectEqual(true, err.toString().includes(expected));
}, fn);
}

View File

@@ -124,12 +124,13 @@ const CreateElementOptions = struct {
};
pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element {
try validateElementName(name);
const namespace: Element.Namespace = blk: {
if (self._type == .xml) {
@branchHint(.unlikely);
break :blk .xml;
}
if (self._type == .html) {
break :blk .html;
}
// Generic and XML documents create XML elements
break :blk .xml;
};
const node = try page.createElementNS(namespace, name, null);
const element = node.as(Element);
@@ -149,6 +150,7 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement
}
pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element {
try validateElementName(name);
const node = try page.createElementNS(Element.Namespace.parse(namespace), name, null);
// Track owner document if it's not the main document
@@ -432,20 +434,103 @@ pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !*
}
pub fn append(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
try validateDocumentNodes(self, nodes, false);
page.domChanged();
const parent = self.asNode();
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
_ = try parent.appendChild(child, page);
// DocumentFragments are special - append all their children
if (child.is(Node.DocumentFragment)) |_| {
try page.appendAllChildren(child, parent);
continue;
}
var child_connected = false;
if (child._parent) |previous_parent| {
child_connected = child.isConnected();
page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
try page.appendNode(parent, child, .{ .child_already_connected = child_connected });
}
}
pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
try validateDocumentNodes(self, nodes, false);
page.domChanged();
const parent = self.asNode();
const parent_is_connected = parent.isConnected();
var i = nodes.len;
while (i > 0) {
i -= 1;
const child = try nodes[i].toNode(page);
_ = try parent.insertBefore(child, parent.firstChild(), page);
// DocumentFragments are special - need to insert all their children
if (child.is(Node.DocumentFragment)) |frag| {
const first_child = parent.firstChild();
var frag_child = frag.asNode().lastChild();
while (frag_child) |fc| {
const prev = fc.previousSibling();
page.removeNode(frag.asNode(), fc, .{ .will_be_reconnected = parent_is_connected });
if (first_child) |before| {
try page.insertNodeRelative(parent, fc, .{ .before = before }, .{});
} else {
try page.appendNode(parent, fc, .{});
}
frag_child = prev;
}
continue;
}
var child_connected = false;
if (child._parent) |previous_parent| {
child_connected = child.isConnected();
page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
const first_child = parent.firstChild();
if (first_child) |before| {
try page.insertNodeRelative(parent, child, .{ .before = before }, .{ .child_already_connected = child_connected });
} else {
try page.appendNode(parent, child, .{ .child_already_connected = child_connected });
}
}
}
pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
try validateDocumentNodes(self, nodes, true);
page.domChanged();
const parent = self.asNode();
// Remove all existing children
var it = parent.childrenIterator();
while (it.next()) |child| {
page.removeNode(parent, child, .{ .will_be_reconnected = false });
}
// Append new children
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
// DocumentFragments are special - append all their children
if (child.is(Node.DocumentFragment)) |_| {
try page.appendAllChildren(child, parent);
continue;
}
var child_connected = false;
if (child._parent) |previous_parent| {
child_connected = child.isConnected();
page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
try page.appendNode(parent, child, .{ .child_already_connected = child_connected });
}
}
@@ -491,7 +576,13 @@ pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const
return result.items;
}
pub fn getDocType(_: *const Document) ?*DocumentType {
pub fn getDocType(self: *Document) ?*Node {
var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{});
while (tw.next()) |node| {
if (node._type == .document_type) {
return node;
}
}
return null;
}
@@ -693,6 +784,118 @@ pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void {
self._adopted_style_sheets = try sheets.persist();
}
// Validates that nodes can be inserted into a Document, respecting Document constraints:
// - At most one Element child
// - At most one DocumentType child
// - No Document, Attribute, or Text nodes
// - Only Element, DocumentType, Comment, and ProcessingInstruction are allowed
// When replacing=true, existing children are not counted (for replaceChildren)
fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, comptime replacing: bool) !void {
const parent = self.asNode();
// Check existing elements and doctypes (unless we're replacing all children)
var has_element = false;
var has_doctype = false;
if (!replacing) {
var it = parent.childrenIterator();
while (it.next()) |child| {
if (child._type == .element) {
has_element = true;
} else if (child._type == .document_type) {
has_doctype = true;
}
}
}
// Validate new nodes
for (nodes) |node_or_text| {
switch (node_or_text) {
.text => {
// Text nodes are not allowed as direct children of Document
return error.HierarchyError;
},
.node => |child| {
// Check if it's a DocumentFragment - need to validate its children
if (child.is(Node.DocumentFragment)) |frag| {
var frag_it = frag.asNode().childrenIterator();
while (frag_it.next()) |frag_child| {
// Document can only contain: Element, DocumentType, Comment, ProcessingInstruction
switch (frag_child._type) {
.element => {
if (has_element) {
return error.HierarchyError;
}
has_element = true;
},
.document_type => {
if (has_doctype) {
return error.HierarchyError;
}
has_doctype = true;
},
.cdata => |cd| switch (cd._type) {
.comment, .processing_instruction => {}, // Allowed
.text, .cdata_section => return error.HierarchyError, // Not allowed in Document
},
.document, .attribute, .document_fragment => return error.HierarchyError,
}
}
} else {
// Validate node type for direct insertion
switch (child._type) {
.element => {
if (has_element) {
return error.HierarchyError;
}
has_element = true;
},
.document_type => {
if (has_doctype) {
return error.HierarchyError;
}
has_doctype = true;
},
.cdata => |cd| switch (cd._type) {
.comment, .processing_instruction => {}, // Allowed
.text, .cdata_section => return error.HierarchyError, // Not allowed in Document
},
.document, .attribute, .document_fragment => return error.HierarchyError,
}
}
// Check for cycles
if (child.contains(parent)) {
return error.HierarchyError;
}
},
}
}
}
fn validateElementName(name: []const u8) !void {
if (name.len == 0) {
return error.InvalidCharacterError;
}
const first = name[0];
// Element names cannot start with: digits, period, hyphen
if ((first >= '0' and first <= '9') or first == '.' or first == '-') {
return error.InvalidCharacterError;
}
for (name[1..]) |c| {
const is_valid = (c >= 'a' and c <= 'z') or
(c >= 'A' and c <= 'Z') or
(c >= '0' and c <= '9') or
c == '_' or c == '-' or c == '.' or c == ':';
if (!is_valid) {
return error.InvalidCharacterError;
}
}
}
const ReadyState = enum {
loading,
interactive,
@@ -731,8 +934,8 @@ pub const JsApi = struct {
pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
pub const domain = bridge.accessor(Document.getDomain, null, .{});
pub const createElement = bridge.function(Document.createElement, .{});
pub const createElementNS = bridge.function(Document.createElementNS, .{});
pub const createElement = bridge.function(Document.createElement, .{.dom_exception = true});
pub const createElementNS = bridge.function(Document.createElementNS, .{.dom_exception = true});
pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{});
pub const createComment = bridge.function(Document.createComment, .{});
pub const createTextNode = bridge.function(Document.createTextNode, .{});
@@ -762,8 +965,9 @@ pub const JsApi = struct {
pub const getElementsByName = bridge.function(Document.getElementsByName, .{});
pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true });
pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true });
pub const append = bridge.function(Document.append, .{});
pub const prepend = bridge.function(Document.prepend, .{});
pub const append = bridge.function(Document.append, .{ .dom_exception = true });
pub const prepend = bridge.function(Document.prepend, .{ .dom_exception = true });
pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .dom_exception = true });
pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{});
pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{});
pub const write = bridge.function(Document.write, .{ .dom_exception = true });

View File

@@ -215,6 +215,15 @@ pub fn getDocType(self: *HTMLDocument, page: *Page) !*DocumentType {
if (self._document_type) |dt| {
return dt;
}
var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{});
while (tw.next()) |node| {
if (node._type == .document_type) {
self._document_type = node.as(DocumentType);
return self._document_type.?;
}
}
self._document_type = try page._factory.node(DocumentType{
._proto = undefined,
._name = "html",

View File

@@ -378,8 +378,14 @@ pub fn isConnected(self: *const Node) bool {
root = parent;
}
// A node is connected if its root is a document
return root._type == .document;
switch (root._type) {
.document => return true,
.document_fragment => |df| {
const sr = df.is(ShadowRoot) orelse return false;
return sr._host.asNode().isConnected();
},
else => return false,
}
}
const GetRootNodeOpts = struct {