webapi: class tokens split on all ASCII whitespace; table row collections

Fixes 7 failing WPT /dom/nodes/getElementsByClassName-*.htm files:

- Class attribute matching (getElementsByClassName and .class
  selectors) treated only the space character as a token separator, so
  class="a\nb" or class="a\tb" never matched "a". Per the spec the
  separators are ASCII whitespace (tab, LF, FF, CR, space);
  classAttributeContains now accepts all of them at token boundaries
  (deliberately not std.ascii.isWhitespace, which would also accept
  vertical tab). Fixes getElementsByClassName-02/04/15/22/25.

- getElementsByClassName-20 exercised table.tBodies[0].rows[0].cells[0]
  and -21 table.deleteRow(1); the table interfaces were bare stubs.
  Added HTMLTableElement.tBodies (child tbody collection),
  HTMLTableSectionElement.rows (child tr collection),
  HTMLTableRowElement.cells (child td/th collection, a new `cells`
  NodeLive mode since child_tag filters a single tag), and
  HTMLTableElement.deleteRow with the spec's row ordering (thead rows,
  then table/tbody rows in tree order, then tfoot rows), -1 meaning the
  last row, and IndexSizeError for out-of-range indices.

Coverage: getElementsByClassName-02/04/15/20/21/22/25.htm each
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:44:50 +02:00
parent ce06c1c5dd
commit 7f1b1640e2
6 changed files with 110 additions and 3 deletions

View File

@@ -32,6 +32,7 @@ const Mode = enum {
all_elements,
child_elements,
child_tag,
cells,
selected_options,
links,
anchors,
@@ -49,6 +50,7 @@ _data: union(Mode) {
all_elements: NodeLive(.all_elements),
child_elements: NodeLive(.child_elements),
child_tag: NodeLive(.child_tag),
cells: NodeLive(.cells),
selected_options: NodeLive(.selected_options),
links: NodeLive(.links),
anchors: NodeLive(.anchors),
@@ -92,6 +94,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator {
.all_elements => |*impl| .{ .all_elements = impl._tw.clone() },
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
.child_tag => |*impl| .{ .child_tag = impl._tw.clone() },
.cells => |*impl| .{ .cells = impl._tw.clone() },
.selected_options => |*impl| .{ .selected_options = impl._tw.clone() },
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
@@ -112,6 +115,7 @@ pub const Iterator = GenericIterator(struct {
all_elements: TreeWalker.FullExcludeSelf,
child_elements: TreeWalker.Children,
child_tag: TreeWalker.Children,
cells: TreeWalker.Children,
selected_options: TreeWalker.Children,
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
@@ -128,6 +132,7 @@ pub const Iterator = GenericIterator(struct {
.all_elements => |*impl| impl.nextTw(&self.tw.all_elements),
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
.child_tag => |*impl| impl.nextTw(&self.tw.child_tag),
.cells => |*impl| impl.nextTw(&self.tw.cells),
.selected_options => |*impl| impl.nextTw(&self.tw.selected_options),
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),

View File

@@ -39,6 +39,7 @@ const Mode = enum {
all_elements,
child_elements,
child_tag,
cells,
selected_options,
links,
anchors,
@@ -59,6 +60,7 @@ const Filters = union(Mode) {
all_elements,
child_elements,
child_tag: Element.Tag,
cells,
selected_options,
links,
anchors,
@@ -91,7 +93,7 @@ pub fn NodeLive(comptime mode: Mode) type {
const Filter = Filters.TypeOf(mode);
const TW = switch (mode) {
.tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
.child_elements, .child_tag, .selected_options => TreeWalker.Children,
.child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children,
};
return struct {
_tw: TW,
@@ -283,6 +285,12 @@ pub fn NodeLive(comptime mode: Mode) type {
const el = node.is(Element) orelse return false;
return el.getTag() == self._filter;
},
.cells => {
// HTMLTableRowElement.cells: td and th children.
const el = node.is(Element) orelse return false;
const tag = el.getTag();
return tag == .td or tag == .th;
},
.selected_options => {
const el = node.is(Element) orelse return false;
const Option = Element.Html.Option;
@@ -373,6 +381,7 @@ pub fn NodeLive(comptime mode: Mode) type {
.all_elements => HTMLCollection{ ._data = .{ .all_elements = self } },
.child_elements => HTMLCollection{ ._data = .{ .child_elements = self } },
.child_tag => HTMLCollection{ ._data = .{ .child_tag = self } },
.cells => HTMLCollection{ ._data = .{ .cells = self } },
.selected_options => HTMLCollection{ ._data = .{ .selected_options = self } },
.links => HTMLCollection{ ._data = .{ .links = self } },
.anchors => HTMLCollection{ ._data = .{ .anchors = self } },

View File

@@ -1,7 +1,11 @@
const std = @import("std");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const collections = @import("../../collections.zig");
const Table = @This();
@@ -14,6 +18,67 @@ pub fn asNode(self: *Table) *Node {
return self.asElement().asNode();
}
pub fn getTBodies(self: *Table, frame: *Frame) collections.NodeLive(.child_tag) {
return collections.NodeLive(.child_tag).init(self.asNode(), .tbody, frame);
}
// The table's rows in spec order: rows of thead children first, then tr
// children of the table and rows of tbody children in tree order, then rows
// of tfoot children.
fn collectRows(self: *Table, frame: *Frame) !std.ArrayList(*Node) {
var rows: std.ArrayList(*Node) = .empty;
const arena = frame.call_arena;
try self.appendSectionRows(.thead, &rows, arena);
var it = self.asNode().childrenIterator();
while (it.next()) |child| {
const el = child.is(Element) orelse continue;
switch (el.getTag()) {
.tr => try rows.append(arena, child),
.tbody => try appendChildRows(child, &rows, arena),
else => {},
}
}
try self.appendSectionRows(.tfoot, &rows, arena);
return rows;
}
fn appendSectionRows(self: *Table, tag: Element.Tag, rows: *std.ArrayList(*Node), arena: std.mem.Allocator) !void {
var it = self.asNode().childrenIterator();
while (it.next()) |child| {
const el = child.is(Element) orelse continue;
if (el.getTag() != tag) continue;
try appendChildRows(child, rows, arena);
}
}
fn appendChildRows(section: *Node, rows: *std.ArrayList(*Node), arena: std.mem.Allocator) !void {
var it = section.childrenIterator();
while (it.next()) |child| {
const el = child.is(Element) orelse continue;
if (el.getTag() == .tr) {
try rows.append(arena, child);
}
}
}
pub fn deleteRow(self: *Table, index: i32, frame: *Frame) !void {
const rows = try self.collectRows(frame);
const len: i32 = @intCast(rows.items.len);
const idx: i32 = if (index == -1) len - 1 else index;
if (idx == -1 and index == -1) {
// deleteRow(-1) on a rowless table is a no-op.
return;
}
if (idx < 0 or idx >= len) {
return error.IndexSizeError;
}
const row = rows.items[@intCast(idx)];
_ = try row.parentNode().?.removeChild(row, frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Table);
@@ -22,4 +87,7 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const tBodies = bridge.accessor(Table.getTBodies, null, .{});
pub const deleteRow = bridge.function(Table.deleteRow, .{ .ce_reactions = true });
};

View File

@@ -1,7 +1,9 @@
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const collections = @import("../../collections.zig");
const TableRow = @This();
@@ -14,6 +16,10 @@ pub fn asNode(self: *TableRow) *Node {
return self.asElement().asNode();
}
pub fn getCells(self: *TableRow, frame: *Frame) collections.NodeLive(.cells) {
return collections.NodeLive(.cells).init(self.asNode(), {}, frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(TableRow);
@@ -22,4 +28,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const cells = bridge.accessor(TableRow.getCells, null, .{});
};

View File

@@ -2,8 +2,10 @@ const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Frame = @import("../../../Frame.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const collections = @import("../../collections.zig");
const String = lp.String;
@@ -20,6 +22,10 @@ pub fn asNode(self: *TableSection) *Node {
return self.asElement().asNode();
}
pub fn getRows(self: *TableSection, frame: *Frame) collections.NodeLive(.child_tag) {
return collections.NodeLive(.child_tag).init(self.asNode(), .tr, frame);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(TableSection);
@@ -28,4 +34,6 @@ pub const JsApi = struct {
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const rows = bridge.accessor(TableSection.getRows, null, .{});
};

View File

@@ -187,9 +187,9 @@ pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bo
var search = class_attr;
while (std.mem.indexOf(u8, search, class_name)) |pos| {
const is_start = pos == 0 or search[pos - 1] == ' ';
const is_start = pos == 0 or isClassWhitespace(search[pos - 1]);
const end = pos + class_name.len;
const is_end = end == search.len or search[end] == ' ';
const is_end = end == search.len or isClassWhitespace(search[end]);
if (is_start and is_end) return true;
@@ -198,6 +198,15 @@ pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bo
return false;
}
// The class attribute tokens are separated by ASCII whitespace (which,
// unlike std.ascii.isWhitespace, does not include vertical tab).
fn isClassWhitespace(c: u8) bool {
return switch (c) {
'\t', '\n', 0x0C, '\r', ' ' => true,
else => false,
};
}
pub const Part = union(enum) {
id: []const u8,
class: []const u8,