From fd174986cf9c0d95660287f364bdf50b38a17daa Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 13:19:12 +0200 Subject: [PATCH 1/4] selector: implement :link, :lang, :empty spec semantics; parser fixes Fixes the remaining 30 failing subtests of WPT /dom/nodes/Element-matches.html, in five clusters: Matching (selector/List.zig): - :link now matches a and area elements with an href attribute (in a headless browser no link is ever visited, so :visited stays unmatched and :link covers every hyperlink). :any-link gains the missing area case. - :empty now ignores comment and processing-instruction children, per Selectors Level 3 only elements and non-empty text/cdata affect emptiness (

is :empty,

is not). - :lang() is implemented: the element's language is the nearest ancestor-or-self lang attribute, falling back to the UA default (en) for elements in a document and to no language at all in detached subtrees, which is what the WPT expects for the detached/fragment contexts. Matching is ASCII case-insensitive on the exact tag or a `-` separated prefix (:lang(en) matches lang="en-AU"). Parsing (selector/Parser.zig): - An empty selector-list segment ("div," or ",div") is now a parse error instead of being silently skipped. - Unexpected EOF closes open attribute brackets per CSS Syntax: '#attr-value [align="center"' parses and matches. - Attribute selectors accept a namespace component: [*|TiTlE] (any namespace) and [|title] (no namespace). Attributes are stored by qualified name and almost never namespaced, so both forms match by name; [*|*=test] stays invalid. Coverage: - /dom/nodes/Element-matches.html 639/669 -> 669/669 (fully green) - /dom/nodes/ParentNode-querySelectors-namespaces.html 0/1 -> 1/1 - /dom/nodes/moveBefore/moveBefore-lang.html 0/1 -> 1/1 - /dom/nodes/moveBefore/Node-moveBefore.html 31/32 -> 32/32 Co-Authored-By: Claude Fable 5 --- src/browser/webapi/selector/List.zig | 57 ++++++++++++++++++++++---- src/browser/webapi/selector/Parser.zig | 37 +++++++++++++---- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index e5cbfd5e6..8bea494f5 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -612,13 +612,15 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N }, .focus_visible => return false, - // Link states - .link => return false, - .visited => return false, - .any_link => { - if (el.getTag() != .anchor) return false; + // Link states. In a headless browser no link is ever visited, so + // :link matches every hyperlink (a or area with an href attribute) + // and :visited matches nothing. + .link, .any_link => { + const tag = el.getTag(); + if (tag != .anchor and tag != .area) return false; return el.getAttributeSafe(comptime .wrap("href")) != null; }, + .visited => return false, .target => { const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse return false; const doc = node.ownerDocument(frame) orelse return false; @@ -638,7 +640,19 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N return node == scope; }, .empty => { - return node.firstChild() == null; + // Only element and content (non-empty text/cdata) children affect + // emptiness; comments and processing instructions are ignored. + var child = node.firstChild(); + while (child) |c| : (child = c.nextSibling()) { + switch (c._type) { + .cdata => |cdata| switch (cdata._type) { + .comment, .processing_instruction => {}, + else => if (cdata.getLength() > 0) return false, + }, + else => return false, + } + } + return true; }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), @@ -660,7 +674,36 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N }, // Functional - .lang => return false, + .lang => |expected| { + if (expected.len == 0) return false; + // The element's language is the nearest ancestor-or-self lang + // attribute. Elements in a document with no declared language + // fall back to the UA default (en); detached subtrees have no + // language at all. + var lang: ?[]const u8 = null; + var current: ?*Node = node; + while (current) |cur| : (current = cur.parentNode()) { + switch (cur._type) { + .element => |ancestor| { + if (ancestor.getAttributeSafe(comptime .wrap("lang"))) |value| { + lang = value; + break; + } + }, + .document => { + lang = "en"; + break; + }, + else => {}, + } + } + const value = lang orelse return false; + // Match the exact language or a `-` separated sub-tag prefix + // (:lang(en) matches lang="en-AU"), ASCII case-insensitively. + if (value.len < expected.len) return false; + if (!std.ascii.eqlIgnoreCase(value[0..expected.len], expected)) return false; + return value.len == expected.len or value[expected.len] == '-'; + }, .not => |selectors| { for (selectors) |selector| { if (matches(node, selector, scope, frame)) { diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index caf6f1d7f..29adb4b3b 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -81,7 +81,11 @@ pub fn parseList(arena: Allocator, input: []const u8) ParseError![]const Selecto var remaining = preprocessed; while (true) { const trimmed = std.mem.trimLeft(u8, remaining, &std.ascii.whitespace); - if (trimmed.len == 0) break; + if (trimmed.len == 0) { + // Either an empty selector or a dangling comma ("div,"): both are + // parse errors per the selector-list grammar. + return error.InvalidSelector; + } var comma_pos: usize = trimmed.len; var depth: usize = 0; @@ -137,10 +141,12 @@ pub fn parseList(arena: Allocator, input: []const u8) ParseError![]const Selecto const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace); - if (selector_input.len > 0) { - const selector = try parse(arena, selector_input); - try selectors.append(arena, selector); + if (selector_input.len == 0) { + // An empty segment between commas (",div") is a parse error. + return error.InvalidSelector; } + const selector = try parse(arena, selector_input); + try selectors.append(arena, selector); if (comma_pos >= trimmed.len) break; remaining = trimmed[comma_pos + 1 ..]; @@ -946,6 +952,15 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute { self.input = self.input[1..]; _ = self.skipSpaces(); + // Optional namespace component: `*|name` (any namespace) or `|name` + // (no namespace). Attributes are stored by qualified name and almost + // never namespaced, so both forms match by name. + if (self.peek() == '*' and self.input.len > 1 and self.input[1] == '|') { + self.input = self.input[2..]; + } else if (self.peek() == '|' and self.input.len > 1 and self.input[1] != '=') { + self.input = self.input[1..]; + } + const attr_name = try self.attributeName(arena); // Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup) @@ -953,8 +968,12 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute { var case_insensitive = false; _ = self.skipSpaces(); - if (self.peek() == ']') { - self.input = self.input[1..]; + // Unexpected EOF closes all open constructs per CSS Syntax, so a + // dangling `[foo` is a valid presence selector. + if (self.peek() == ']' or self.peek() == 0) { + if (self.peek() == ']') { + self.input = self.input[1..]; + } return .{ .name = name, .matcher = .presence, .case_insensitive = case_insensitive }; } @@ -976,10 +995,12 @@ fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute { _ = self.skipSpaces(); } - if (self.peek() != ']') { + // As above, EOF stands in for the closing bracket ([align="center"). + if (self.peek() == ']') { + self.input = self.input[1..]; + } else if (self.peek() != 0) { return error.InvalidAttributeSelector; } - self.input = self.input[1..]; const matcher: Selector.AttributeMatcher = switch (matcher_type) { .exact => .{ .exact = value }, From 152f1eb8eb51d92d9619121a68e9f97be2fb5902 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 13:28:13 +0200 Subject: [PATCH 2/4] webapi: script-created documents are UTF-8; URLs use the document encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the last failing subtest of WPT /dom/nodes/DOMImplementation-createHTMLDocument.html ("URL parsing"): resolving `a.href = "http://example.org/?รค"` on an anchor inside a createHTMLDocument() document returned "?%E4" instead of "?%C3%A4". Two spec violations combined: - Per DOM, documents synthesized by script (createHTMLDocument, createDocument, new Document()) have the UTF-8 encoding. We had no per-document encoding at all: document.characterSet always reflected the frame's charset, so the new document inherited windows-1252 from the test page (which uses that encoding deliberately to catch this). Document gains a _charset override (same pattern as _content_type), set to UTF-8 in DOMImplementation.createHTMLDocument/createDocument and the Document constructor. - Node.resolveURL encoded the query string with the frame's charset. Per the URL/HTML specs the query percent-encoding uses the encoding of the element's node document, so it now resolves the owning document and uses its encoding, falling back to the frame's. Coverage: - /dom/nodes/DOMImplementation-createHTMLDocument.html 12/13 -> 13/13 (fully green) - /dom/nodes/Document-constructor.html 3/5 -> 4/5 Co-Authored-By: Claude Fable 5 --- src/browser/webapi/DOMImplementation.zig | 2 ++ src/browser/webapi/Document.zig | 15 +++++++++++++-- src/browser/webapi/Node.zig | 6 +++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index 1e0a44b69..cce49bd0e 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -50,6 +50,7 @@ pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString const document = (try frame._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument(); document._ready_state = .complete; document._url = "about:blank"; + document._charset = "UTF-8"; { const doctype = try frame._factory.node(DocumentType{ @@ -101,6 +102,7 @@ pub fn createDocument(_: *const DOMImplementation, namespace_nullable: js.Nullab // Create XML Document const document = (try frame._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument(); document._url = "about:blank"; + document._charset = "UTF-8"; // Per spec the content type depends on the requested namespace. document._content_type = blk: { const ns = namespace_ orelse break :blk "application/xml"; diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index b34de50ba..ec32cdc3b 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -56,6 +56,9 @@ _frame: ?*Frame = null, _url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank) // content type override for documents created via DOMImplementation.createDocument _content_type: ?[]const u8 = null, +// encoding override: documents synthesized by script (createHTMLDocument, +// createDocument) are UTF-8 regardless of the frame's encoding +_charset: ?[]const u8 = null, _ready_state: ReadyState = .loading, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, @@ -159,6 +162,14 @@ pub fn setLocation(self: *Document, url: [:0]const u8) !void { return frame.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = frame }); } +pub fn getCharset(self: *const Document) []const u8 { + if (self._charset) |charset| { + return charset; + } + const doc_frame = self._frame orelse return "UTF-8"; + return doc_frame.charset; +} + pub fn getContentType(self: *const Document) []const u8 { if (self._content_type) |content_type| { return content_type; @@ -1417,6 +1428,7 @@ pub const JsApi = struct { return frame._factory.node(Document{ ._proto = undefined, ._type = .generic, + ._charset = "UTF-8", }); } @@ -1508,8 +1520,7 @@ pub const JsApi = struct { pub const compatMode = bridge.property("CSS1Compat", .{ .template = false }); fn getCharacterSet(self: *const Document) []const u8 { - const doc_frame = self._frame orelse return "UTF-8"; - return doc_frame.charset; + return self.getCharset(); } pub const referrer = bridge.property("", .{ .template = false }); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 2151e1d14..bd0157a6f 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -582,7 +582,11 @@ pub const ResolveURLOpts = struct { pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveURLOpts) ![:0]const u8 { const owner_frame = self.ownerFrame(frame); const allocator = opts.allocator orelse frame.call_arena; - return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = owner_frame.charset }); + // The owning document's encoding, not the frame's: script-created + // documents (e.g. createHTMLDocument) are always UTF-8. + const doc: ?*const Document = if (self._type == .document) self._type.document else self.ownerDocument(frame); + const encoding = if (doc) |d| d.getCharset() else owner_frame.charset; + return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = encoding }); } // Same as `resolveURL` but can't return `TypeError`, this is needed for multiple From d357f093c0a0805bb2a61a5a3f6df0bd8a318a7c Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 13:35:41 +0200 Subject: [PATCH 3/4] webapi: HTMLCollection named properties are not enumerable in for-in Fixes the failing subtest of WPT /dom/nodes/Element-children.html ("HTMLCollection edge cases 1"): iterating an HTMLCollection with for-in must yield only the supported indices; the supported names are [LegacyUnenumerableNamedProperties] and must be skipped, while Object.getOwnPropertyNames still returns indices + names. v8 filters for-in through the named query interceptor, which HTMLCollection did not register (only a descriptor callback), so every name reported by the enumerator was treated as enumerable. HTMLCollection now registers a named query reporting DontEnum for supported names. The query deliberately does not report ReadOnly: v8 also consults it when a [[Set]] walks the prototype chain, and a read-only property on the prototype would block the shadowing expando that the spec's ignore-named-props rule requires (HTMLCollection-as-prototype.html). Writability as observed through getOwnPropertyDescriptor still comes from the descriptor callback, and direct assignments to supported names still fail through the definer. Coverage: /dom/nodes/Element-children.html 1/2 -> 2/2 (fully green); /dom/collections stays fully green (53/53). Co-Authored-By: Claude Fable 5 --- .../webapi/collections/HTMLCollection.zig | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 83f0224c5..36d2b5443 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -180,7 +180,23 @@ pub const JsApi = struct { return self.getByName(name, frame) orelse error.NotHandled; } - }.wrap, null, deleteByName, getNames, null, defineByName, describeByName, .{ .null_as_undefined = true }); + }.wrap, null, deleteByName, getNames, queryByName, defineByName, describeByName, .{ .null_as_undefined = true }); + + // Named properties are [LegacyUnenumerableNamedProperties]: the query + // reports them non-enumerable, so for-in skips them while + // Object.getOwnPropertyNames still lists them via the enumerator. + // Deliberately NOT ReadOnly: v8 consults the query when a [[Set]] walks + // the prototype chain, and a read-only property on the prototype would + // block the expando that the spec's ignore-named-props rule requires + // (HTMLCollection-as-prototype.html). Writability as seen by + // getOwnPropertyDescriptor comes from describeByName, and direct + // assignments still fail through defineByName. + fn queryByName(self: *HTMLCollection, name: []const u8, frame: *Frame) !u32 { + if (name.len > 0 and self.getByName(name, frame) != null) { + return js.v8.DontEnum; + } + return error.NotHandled; + } // HTMLCollection has no indexed setter: per Web IDL, assigning to or // defining any array index property fails (TypeError in strict mode). From e5257f9bc35270ae3b2cadfd8753403bbc15fad3 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 16:27:28 +0800 Subject: [PATCH 4/4] Use node childrenIterator Introduce node.ownerDocumentIncludingSelf() to remove duplicate logic through Node.zig. --- src/browser/webapi/Node.zig | 26 +++++----- .../webapi/collections/HTMLCollection.zig | 6 --- src/browser/webapi/selector/List.zig | 47 ++++++++++--------- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index bd0157a6f..397a18102 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -558,6 +558,13 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document { return frame.document; } +fn ownerDocumentIncludingSelf(self: *const Node, frame: *const Frame) ?*Document { + if (self._type == .document) { + return self._type.document; + } + return self.ownerDocument(frame); +} + // Returns the Frame that owns this node's tree. Used to tie cached state of // "live" collections (NodeList, HTMLCollection, etc.) to the right frame's DOM // version: cross-realm callers must invalidate based on mutations through the @@ -566,10 +573,7 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document { // Falls back to `default` when the node has no associated document yet (e.g., // freshly created and detached) or its document has no frame. pub fn ownerFrame(self: *const Node, default: *Frame) *Frame { - if (self._type == .document) { - return self._type.document._frame orelse default; - } - const doc = self.ownerDocument(default) orelse return default; + const doc = self.ownerDocumentIncludingSelf(default) orelse return default; return doc._frame orelse default; } @@ -582,9 +586,7 @@ pub const ResolveURLOpts = struct { pub fn resolveURL(self: *const Node, url: anytype, frame: *Frame, opts: ResolveURLOpts) ![:0]const u8 { const owner_frame = self.ownerFrame(frame); const allocator = opts.allocator orelse frame.call_arena; - // The owning document's encoding, not the frame's: script-created - // documents (e.g. createHTMLDocument) are always UTF-8. - const doc: ?*const Document = if (self._type == .document) self._type.document else self.ownerDocument(frame); + const doc: ?*const Document = self.ownerDocumentIncludingSelf(frame); const encoding = if (doc) |d| d.getCharset() else owner_frame.charset; return URL.resolve(allocator, owner_frame.base(), url, .{ .encoding = encoding }); } @@ -600,8 +602,8 @@ pub fn resolveURLReflect(self: *const Node, url: []const u8, frame: *Frame, opts pub fn isSameDocumentAs(self: *const Node, other: *const Node, frame: *const Frame) bool { // Get the root document for each node - const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(frame); - const other_doc = if (other._type == .document) other._type.document else other.ownerDocument(frame); + const self_doc = self.ownerDocumentIncludingSelf(frame); + const other_doc = other.ownerDocumentIncludingSelf(frame); return self_doc == other_doc; } @@ -1377,11 +1379,7 @@ pub const JsApi = struct { pub const baseURI = bridge.accessor(_baseURI, null, .{}); fn _baseURI(self: *Node, frame: *const Frame) []const u8 { - const doc = if (self._type == .document) - self._type.document - else - self.ownerDocument(frame) orelse return frame.base(); - + const doc = self.ownerDocumentIncludingSelf(frame) orelse return frame.base(); if (doc._frame) |doc_frame| { return doc_frame.base(); } diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index 36d2b5443..c332b32cb 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -185,12 +185,6 @@ pub const JsApi = struct { // Named properties are [LegacyUnenumerableNamedProperties]: the query // reports them non-enumerable, so for-in skips them while // Object.getOwnPropertyNames still lists them via the enumerator. - // Deliberately NOT ReadOnly: v8 consults the query when a [[Set]] walks - // the prototype chain, and a read-only property on the prototype would - // block the expando that the spec's ignore-named-props rule requires - // (HTMLCollection-as-prototype.html). Writability as seen by - // getOwnPropertyDescriptor comes from describeByName, and direct - // assignments still fail through defineByName. fn queryByName(self: *HTMLCollection, name: []const u8, frame: *Frame) !u32 { if (name.len > 0 and self.getByName(name, frame) != null) { return js.v8.DontEnum; diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index 8bea494f5..81ef82c3f 100644 --- a/src/browser/webapi/selector/List.zig +++ b/src/browser/webapi/selector/List.zig @@ -642,9 +642,9 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N .empty => { // Only element and content (non-empty text/cdata) children affect // emptiness; comments and processing instructions are ignored. - var child = node.firstChild(); - while (child) |c| : (child = c.nextSibling()) { - switch (c._type) { + var it = node.childrenIterator(); + while (it.next()) |child| { + switch (child._type) { .cdata => |cdata| switch (cdata._type) { .comment, .processing_instruction => {}, else => if (cdata.getLength() > 0) return false, @@ -680,29 +680,32 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N // attribute. Elements in a document with no declared language // fall back to the UA default (en); detached subtrees have no // language at all. - var lang: ?[]const u8 = null; - var current: ?*Node = node; - while (current) |cur| : (current = cur.parentNode()) { - switch (cur._type) { - .element => |ancestor| { - if (ancestor.getAttributeSafe(comptime .wrap("lang"))) |value| { - lang = value; - break; - } - }, - .document => { - lang = "en"; - break; - }, - else => {}, + const lang = blk: { + var current: ?*Node = node; + while (current) |cur| : (current = cur.parentNode()) { + switch (cur._type) { + .element => |ancestor| { + if (ancestor.getAttributeSafe(comptime .wrap("lang"))) |value| { + break :blk value; + } + }, + .document => { + break :blk "en"; + }, + else => {}, + } } + return false; + }; + if (lang.len < expected.len) { + return false; } - const value = lang orelse return false; // Match the exact language or a `-` separated sub-tag prefix // (:lang(en) matches lang="en-AU"), ASCII case-insensitively. - if (value.len < expected.len) return false; - if (!std.ascii.eqlIgnoreCase(value[0..expected.len], expected)) return false; - return value.len == expected.len or value[expected.len] == '-'; + if (!std.ascii.eqlIgnoreCase(lang[0..expected.len], expected)) { + return false; + } + return lang.len == expected.len or lang[expected.len] == '-'; }, .not => |selectors| { for (selectors) |selector| {