webapi: Text.wholeText spans contiguous Text siblings

Fixes WPT /dom/nodes/Text-wholeText.html: wholeText must return the
concatenated data of the contiguous exclusive Text nodes (adjacent Text
siblings on both sides of the node) in tree order; we returned only the
node's own data.

The old own-data behavior is what every internal caller (semantic tree,
markdown dump, AX names, textarea default value, textContent) actually
wants, since they iterate the text nodes themselves; those now use the
new Text.ownData helper, and getWholeText implements the spec walk
(single-node case stays allocation-free).

Coverage: /dom/nodes/Text-wholeText.html 0/1 -> 1/1 (fully green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 15:52:07 +02:00
parent 7f1b1640e2
commit cfde0d2bc1
7 changed files with 45 additions and 11 deletions

View File

@@ -139,7 +139,7 @@ fn walk(
if (html_el.getHidden()) return;
}
} else if (node.is(CData.Text)) |text_node| {
const text = text_node.getWholeText();
const text = text_node.ownData();
if (isAllWhitespace(text)) {
return;
}
@@ -414,7 +414,7 @@ const JsonVisitor = struct {
try self.jw.objectField("nodeType");
try self.jw.write(3);
try self.jw.objectField("nodeValue");
try self.jw.write(text_node.getWholeText());
try self.jw.write(text_node.ownData());
} else {
try self.jw.objectField("nodeType");
try self.jw.write(9);
@@ -472,7 +472,7 @@ const TextVisitor = struct {
name_to_print = n;
}
} else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
const trimmed = std.mem.trim(u8, text_node.ownData(), " \t\r\n");
if (trimmed.len > 0) {
name_to_print = trimmed;
}

View File

@@ -448,7 +448,7 @@ pub fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
const content = std.mem.trim(u8, text.ownData(), &std.ascii.whitespace);
if (content.len > 0) {
if (single_chunk == null and arr.items.len == 0) {
single_chunk = content;

View File

@@ -142,7 +142,7 @@ fn isStandaloneAnchor(el: *Element) bool {
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.getWholeText());
return !isAllWhitespace(text.ownData());
}
fn isVisibleElement(el: *Element) bool {

View File

@@ -1175,11 +1175,11 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra
if (next_node) |next| {
if (next.is(CData.Text)) |_| {
try buffer.appendSlice(allocator, text_node.getWholeText());
try buffer.appendSlice(allocator, text_node.ownData());
while (next_node) |node_to_merge| {
const next_text_node = node_to_merge.is(CData.Text) orelse break;
try buffer.appendSlice(allocator, next_text_node.getWholeText());
try buffer.appendSlice(allocator, next_text_node.ownData());
const to_remove = node_to_merge;
next_node = node_to_merge.nextSibling();

View File

@@ -16,8 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const CData = @import("../CData.zig");
const Slot = @import("../element/html/Slot.zig");
const slotting = @import("../element/slotting.zig");
@@ -31,10 +34,41 @@ pub fn init(str: ?js.NullableString, frame: *Frame) !*Text {
return node.as(Text);
}
pub fn getWholeText(self: *Text) []const u8 {
// This Text node's own data (getWholeText below spans adjacent Text nodes).
pub fn ownData(self: *const Text) []const u8 {
return self._proto._data.str();
}
// The concatenated data of the contiguous exclusive Text nodes (adjacent
// Text siblings on both sides of this one), in tree order.
pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 {
const node = self._proto.asNode();
var first = node;
while (first.previousSibling()) |prev| {
if (!isExclusiveTextNode(prev)) break;
first = prev;
}
// Common case: no adjacent text nodes, return our data directly.
const has_next_text = if (node.nextSibling()) |next| isExclusiveTextNode(next) else false;
if (first == node and !has_next_text) {
return self._proto._data.str();
}
var buf: std.ArrayList(u8) = .empty;
var current: ?*Node = first;
while (current) |cur| : (current = cur.nextSibling()) {
if (!isExclusiveTextNode(cur)) break;
try buf.appendSlice(frame.call_arena, cur._type.cdata._data.str());
}
return buf.items;
}
fn isExclusiveTextNode(node: *const Node) bool {
return node._type == .cdata and node._type.cdata._type == .text;
}
pub fn getAssignedSlot(self: *Text, frame: *Frame) ?*Slot {
return slotting.findSlot(self._proto.asNode(), true, frame);
}

View File

@@ -90,7 +90,7 @@ pub fn getDefaultValue(self: *const TextArea) []const u8 {
const node = self.asConstNode();
if (node.firstChild()) |child| {
if (child.is(Node.CData.Text)) |txt| {
return txt.getWholeText();
return txt.ownData();
}
}
return "";

View File

@@ -989,7 +989,7 @@ fn writeName(
},
.cdata => |cd| switch (cd._type) {
.text => |*text| {
try writeString(text.getWholeText(), w);
try writeString(text.ownData(), w);
return .contents;
},
else => null,
@@ -1109,7 +1109,7 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, frame: *F
switch (child._type) {
.cdata => |cd| switch (cd._type) {
.text => |*text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
const content = std.mem.trim(u8, text.ownData(), &std.ascii.whitespace);
if (content.len > 0) {
try writer.writeAll(content);
try writer.writeByte(' ');