Merge pull request #2951 from lightpanda-io/dom-pr-10-selectors-and-encoding

selector/webapi: :link/:lang/:empty semantics, document encoding, for-in enumerability
This commit is contained in:
Karl Seguin
2026-07-15 16:43:38 +08:00
committed by GitHub
6 changed files with 122 additions and 30 deletions

View File

@@ -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";

View File

@@ -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 });

View File

@@ -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();
}

View File

@@ -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).

View File

@@ -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)) {

View File

@@ -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 },