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..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,7 +586,9 @@ 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 }); + 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 }); } // Same as `resolveURL` but can't return `TypeError`, this is needed for multiple @@ -596,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; } @@ -1373,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 83f0224c5..c332b32cb 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -180,7 +180,17 @@ 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. + 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). diff --git a/src/browser/webapi/selector/List.zig b/src/browser/webapi/selector/List.zig index e5cbfd5e6..81ef82c3f 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 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, + }, + else => return false, + } + } + return true; }, .first_child => return isFirstChild(el), .last_child => return isLastChild(el), @@ -660,7 +674,39 @@ 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. + 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; + } + // Match the exact language or a `-` separated sub-tag prefix + // (:lang(en) matches lang="en-AU"), ASCII case-insensitively. + 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| { 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 },