diff --git a/Tests/LibWeb/Text/expected/DOM/Node-normalize.txt b/Tests/LibWeb/Text/expected/DOM/Node-normalize.txt
new file mode 100644
index 00000000000..008baa6bb85
--- /dev/null
+++ b/Tests/LibWeb/Text/expected/DOM/Node-normalize.txt
@@ -0,0 +1,8 @@
+Document fragment initial text: 12, child nodes: 3
+Element initial text: 34, child nodes: 2
+Element text after document.normalize(): 34, child nodes: 2
+Document fragment text after documentFragment.normalize(): 1234, child nodes: 2
+Text node 1 data: 12
+Text node 2 data: 2
+Text node 3 data: 34
+Text node 4 data: 4
\ No newline at end of file
diff --git a/Tests/LibWeb/Text/input/DOM/Node-normalize.html b/Tests/LibWeb/Text/input/DOM/Node-normalize.html
new file mode 100644
index 00000000000..46a5847fb5e
--- /dev/null
+++ b/Tests/LibWeb/Text/input/DOM/Node-normalize.html
@@ -0,0 +1,29 @@
+
+
+
diff --git a/Userland/Libraries/LibWeb/DOM/Node.cpp b/Userland/Libraries/LibWeb/DOM/Node.cpp
index dc5e32b47fd..ab966e0a792 100644
--- a/Userland/Libraries/LibWeb/DOM/Node.cpp
+++ b/Userland/Libraries/LibWeb/DOM/Node.cpp
@@ -212,6 +212,114 @@ void Node::set_text_content(Optional const& maybe_content)
document().bump_dom_tree_version();
}
+// https://dom.spec.whatwg.org/#dom-node-normalize
+WebIDL::ExceptionOr Node::normalize()
+{
+ auto contiguous_exclusive_text_nodes_excluding_self = [](Node& node) {
+ // https://dom.spec.whatwg.org/#contiguous-exclusive-text-nodes
+ // The contiguous exclusive Text nodes of a node node are node, node’s previous sibling exclusive Text node, if any,
+ // and its contiguous exclusive Text nodes, and node’s next sibling exclusive Text node, if any,
+ // and its contiguous exclusive Text nodes, avoiding any duplicates.
+ // NOTE: The callers of this method require node itself to be excluded.
+ Vector nodes;
+
+ auto* current_node = node.previous_sibling();
+ while (current_node) {
+ if (!current_node->is_text())
+ break;
+
+ nodes.append(static_cast(current_node));
+ current_node = current_node->previous_sibling();
+ }
+
+ // Reverse the order of the nodes so that they are in tree order.
+ nodes.reverse();
+
+ current_node = node.next_sibling();
+ while (current_node) {
+ if (!current_node->is_text())
+ break;
+
+ nodes.append(static_cast(current_node));
+ current_node = current_node->next_sibling();
+ }
+
+ return nodes;
+ };
+
+ // The normalize() method steps are to run these steps for each descendant exclusive Text node node of this
+ Vector descendant_exclusive_text_nodes;
+ for_each_in_inclusive_subtree_of_type([&](Text const& node) {
+ if (!node.is_cdata_section())
+ descendant_exclusive_text_nodes.append(const_cast(node));
+
+ return TraversalDecision::Continue;
+ });
+
+ for (auto& node : descendant_exclusive_text_nodes) {
+ // 1. Let length be node’s length.
+ auto& character_data = static_cast(node);
+ auto length = character_data.length_in_utf16_code_units();
+
+ // 2. If length is zero, then remove node and continue with the next exclusive Text node, if any.
+ if (length == 0) {
+ if (node.parent())
+ node.remove();
+ continue;
+ }
+
+ // 3. Let data be the concatenation of the data of node’s contiguous exclusive Text nodes (excluding itself), in tree order.
+ StringBuilder data;
+ for (auto const& text_node : contiguous_exclusive_text_nodes_excluding_self(node))
+ data.append(text_node->data());
+
+ // 4. Replace data with node node, offset length, count 0, and data data.
+ TRY(character_data.replace_data(length, 0, MUST(data.to_string())));
+
+ // 5. Let currentNode be node’s next sibling.
+ auto* current_node = node.next_sibling();
+
+ // 6. While currentNode is an exclusive Text node:
+ while (current_node && is(*current_node)) {
+ // 1. For each live range whose start node is currentNode, add length to its start offset and set its start node to node.
+ for (auto& range : Range::live_ranges()) {
+ if (range->start_container() == current_node)
+ TRY(range->set_start(node, range->start_offset() + length));
+ }
+
+ // 2. For each live range whose end node is currentNode, add length to its end offset and set its end node to node.
+ for (auto& range : Range::live_ranges()) {
+ if (range->end_container() == current_node)
+ TRY(range->set_end(node, range->end_offset() + length));
+ }
+
+ // 3. For each live range whose start node is currentNode’s parent and start offset is currentNode’s index, set its start node to node and its start offset to length.
+ for (auto& range : Range::live_ranges()) {
+ if (range->start_container() == current_node->parent() && range->start_offset() == current_node->index())
+ TRY(range->set_start(node, length));
+ }
+
+ // 4. For each live range whose end node is currentNode’s parent and end offset is currentNode’s index, set its end node to node and its end offset to length.
+ for (auto& range : Range::live_ranges()) {
+ if (range->end_container() == current_node->parent() && range->end_offset() == current_node->index())
+ TRY(range->set_end(node, length));
+ }
+
+ // 5. Add currentNode’s length to length.
+ length += static_cast(*current_node).length();
+
+ // 6. Set currentNode to its next sibling.
+ current_node = current_node->next_sibling();
+ }
+
+ // 7. Remove node’s contiguous exclusive Text nodes (excluding itself), in tree order.
+ for (auto const& text_node : contiguous_exclusive_text_nodes_excluding_self(node))
+ text_node->remove();
+ }
+
+ return {};
+}
+
// https://dom.spec.whatwg.org/#dom-node-nodevalue
Optional Node::node_value() const
{
diff --git a/Userland/Libraries/LibWeb/DOM/Node.h b/Userland/Libraries/LibWeb/DOM/Node.h
index fafc8e760a6..14fd18c2ee0 100644
--- a/Userland/Libraries/LibWeb/DOM/Node.h
+++ b/Userland/Libraries/LibWeb/DOM/Node.h
@@ -156,6 +156,8 @@ public:
Optional text_content() const;
void set_text_content(Optional const&);
+ WebIDL::ExceptionOr normalize();
+
Optional node_value() const;
void set_node_value(Optional const&);
diff --git a/Userland/Libraries/LibWeb/DOM/Node.idl b/Userland/Libraries/LibWeb/DOM/Node.idl
index b2f290f6c16..1ed330eba4e 100644
--- a/Userland/Libraries/LibWeb/DOM/Node.idl
+++ b/Userland/Libraries/LibWeb/DOM/Node.idl
@@ -28,6 +28,7 @@ interface Node : EventTarget {
// However, we only apply it to setters, so this works as a stop gap.
// Replace this with something like a special cased [LegacyNullToEmptyString].
[LegacyNullToEmptyString, CEReactions] attribute DOMString? textContent;
+ [CEReactions] undefined normalize();
[CEReactions] Node appendChild(Node node);
[ImplementedAs=pre_insert, CEReactions] Node insertBefore(Node node, Node? child);