diff --git a/src/browser/tests/cdata/cdata_section.html b/src/browser/tests/cdata/cdata_section.html
index 5c991bb5..6fc24a97 100644
--- a/src/browser/tests/cdata/cdata_section.html
+++ b/src/browser/tests/cdata/cdata_section.html
@@ -201,8 +201,8 @@ cdataClassName
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);
}
diff --git a/src/browser/tests/document/replace_children.html b/src/browser/tests/document/replace_children.html
new file mode 100644
index 00000000..010f3b0d
--- /dev/null
+++ b/src/browser/tests/document/replace_children.html
@@ -0,0 +1,344 @@
+
+
+
+
+ document.replaceChildren Tests
+
+
+ Original content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js
index afc1fa69..62c8473f 100644
--- a/src/browser/tests/testing.js
+++ b/src/browser/tests/testing.js
@@ -36,7 +36,7 @@
function expectError(expected, fn) {
withError((err) => {
- expectEqual(expected, err.toString());
+ expectEqual(true, err.toString().includes(expected));
}, fn);
}
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 3af6a59b..96ce7f03 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -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;
}
- 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 });
diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig
index 18aa9f56..8082ac67 100644
--- a/src/browser/webapi/HTMLDocument.zig
+++ b/src/browser/webapi/HTMLDocument.zig
@@ -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",
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index b8bb1efd..295d957b 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -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 {