webapi: quirks-mode documents match class names case-insensitively

Fixes WPT /dom/nodes/getElementsByClassName-14.htm: in quirks mode
getElementsByClassName must match class names ASCII
case-insensitively (class="a A" matches "a" twice), while Unicode case
stays significant.

The document had no notion of quirks mode at all (compatMode was
hardcoded to "CSS1Compat"). Document.isQuirksMode approximates the
HTML parser's mode: an HTML document without a doctype child is in
quirks mode (legacy doctypes that also trigger quirks are not
detected). compatMode now reports BackCompat accordingly, and
getElementsByClassName bakes the mode into its live-collection filter
(ClassNameFilter), with classAttributeContainsCase doing the
ASCII-case-insensitive token scan.

Coverage: /dom/nodes/getElementsByClassName-14.htm 1/2 -> 2/2 (fully
green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Francis Bouvier
2026-07-11 21:49:34 +02:00
parent 31273fe89a
commit 3e11ba55d9
4 changed files with 46 additions and 7 deletions

View File

@@ -179,6 +179,21 @@ pub fn setLocation(self: *Document, url: [:0]const u8) !void {
return frame.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = frame });
}
// Approximation of quirks mode: an HTML document without a doctype is in
// quirks mode. (Legacy doctypes that also trigger quirks are not detected.)
pub fn isQuirksMode(self: *const Document) bool {
if (self._type != .html) {
return false;
}
var child = self._proto.firstChild();
while (child) |c| : (child = c.nextSibling()) {
if (c._type == .document_type) {
return false;
}
}
return true;
}
pub fn getCharset(self: *const Document) []const u8 {
if (self._charset) |charset| {
return charset;
@@ -1445,6 +1460,7 @@ pub const JsApi = struct {
return frame._factory.node(Document{
._proto = undefined,
._type = .generic,
._url = "about:blank",
._charset = "UTF-8",
});
}
@@ -1534,7 +1550,10 @@ pub const JsApi = struct {
pub const characterSet = bridge.accessor(getCharacterSet, null, .{});
pub const charset = bridge.accessor(getCharacterSet, null, .{});
pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{});
pub const compatMode = bridge.property("CSS1Compat", .{ .template = false });
pub const compatMode = bridge.accessor(getCompatMode, null, .{});
fn getCompatMode(self: *const Document) []const u8 {
return if (self.isQuirksMode()) "BackCompat" else "CSS1Compat";
}
fn getCharacterSet(self: *const Document) []const u8 {
return self.getCharset();

View File

@@ -1398,7 +1398,12 @@ pub fn getElementsByClassName(self: *Node, class_name: []const u8, frame: *Frame
try class_names.append(arena, try frame.dupeString(name));
}
return collections.NodeLive(.class_name).init(self, class_names.items, frame);
const doc: ?*Document = if (self._type == .document) self._type.document else self.ownerDocument(frame);
const quirks = if (doc) |d| d.isQuirksMode() else false;
return collections.NodeLive(.class_name).init(self, .{
.names = class_names.items,
.case_insensitive = quirks,
}, frame);
}
/// Shared implementation of replaceChildren for Element, Document, and DocumentFragment.

View File

@@ -46,6 +46,13 @@ const Mode = enum {
form,
};
pub const ClassNameFilter = struct {
names: [][]const u8,
// getElementsByClassName matches class names ASCII case-insensitively
// when the document is in quirks mode.
case_insensitive: bool = false,
};
pub const TagNameNsFilter = struct {
namespace: ?Element.Namespace, // null means wildcard "*"
local_name: String,
@@ -55,7 +62,7 @@ const Filters = union(Mode) {
tag: Element.Tag,
tag_name: String,
tag_name_ns: TagNameNsFilter,
class_name: [][]const u8,
class_name: ClassNameFilter,
name: []const u8,
all_elements,
child_elements,
@@ -261,14 +268,14 @@ pub fn NodeLive(comptime mode: Mode) type {
return self._filter.local_name.eqlSlice(el.getLocalName());
},
.class_name => {
if (self._filter.len == 0) {
if (self._filter.names.len == 0) {
return false;
}
const el = node.is(Element) orelse return false;
const class_attr = el.getAttributeSafe(comptime .wrap("class")) orelse return false;
for (self._filter) |class_name| {
if (!Selector.classAttributeContains(class_attr, class_name)) {
for (self._filter.names) |class_name| {
if (!Selector.classAttributeContainsCase(class_attr, class_name, self._filter.case_insensitive)) {
return false;
}
}

View File

@@ -183,10 +183,18 @@ pub fn matchesUncached(arena: Allocator, el: *Node.Element, input: []const u8, f
}
pub fn classAttributeContains(class_attr: []const u8, class_name: []const u8) bool {
return classAttributeContainsCase(class_attr, class_name, false);
}
pub fn classAttributeContainsCase(class_attr: []const u8, class_name: []const u8, case_insensitive: bool) bool {
if (class_name.len == 0 or class_name.len > class_attr.len) return false;
var search = class_attr;
while (std.mem.indexOf(u8, search, class_name)) |pos| {
while (if (case_insensitive)
std.ascii.indexOfIgnoreCase(search, class_name)
else
std.mem.indexOf(u8, search, class_name)) |pos|
{
const is_start = pos == 0 or isClassWhitespace(search[pos - 1]);
const end = pos + class_name.len;
const is_end = end == search.len or isClassWhitespace(search[end]);