mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-15 07:19:20 -04:00
Correctly encapsulate events that happen within a shadowroot to stop from leaking out. This involves calculating the related_target relative to the potential node receiving the event. Also Event.composedPath now re-uses the same path-building logic as EventManager (previously, the same-ish algo in both places, that were out of sync). Some smaller changes: - input.list getter (showed up in the shadow dom wpt tests) - activeElement getter added to ShadowDOM - document activeElement is ShadowDOM-aware and won't cross
1671 lines
64 KiB
Zig
1671 lines
64 KiB
Zig
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||
//
|
||
// Francis Bouvier <francis@lightpanda.io>
|
||
// Pierre Tachoire <pierre@lightpanda.io>
|
||
//
|
||
// This program is free software: you can redistribute it and/or modify
|
||
// it under the terms of the GNU Affero General Public License as
|
||
// published by the Free Software Foundation, either version 3 of the
|
||
// License, or (at your option) any later version.
|
||
//
|
||
// This program is distributed in the hope that it will be useful,
|
||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
// GNU Affero General Public License for more details.
|
||
//
|
||
// You should have received a copy of the GNU Affero General Public License
|
||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
const std = @import("std");
|
||
const lp = @import("lightpanda");
|
||
|
||
const js = @import("../js/js.zig");
|
||
const Frame = @import("../Frame.zig");
|
||
const Window = @import("Window.zig");
|
||
const URL = @import("../URL.zig");
|
||
const idna = @import("../../sys/idna.zig");
|
||
const public_suffix_list = @import("../../data/public_suffix_list.zig");
|
||
|
||
const Node = @import("Node.zig");
|
||
const Element = @import("Element.zig");
|
||
const Location = @import("Location.zig");
|
||
const Parser = @import("../parser/Parser.zig");
|
||
const collections = @import("collections.zig");
|
||
const Selector = @import("selector/Selector.zig");
|
||
const DOMTreeWalker = @import("DOMTreeWalker.zig");
|
||
const DOMNodeIterator = @import("DOMNodeIterator.zig");
|
||
const DOMImplementation = @import("DOMImplementation.zig");
|
||
const StyleSheetList = @import("css/StyleSheetList.zig");
|
||
const FontFaceSet = @import("css/FontFaceSet.zig");
|
||
const Selection = @import("Selection.zig");
|
||
const XPathResult = @import("XPathResult.zig");
|
||
const XPathExpression = @import("XPathExpression.zig");
|
||
|
||
pub const XMLDocument = @import("XMLDocument.zig");
|
||
pub const HTMLDocument = @import("HTMLDocument.zig");
|
||
|
||
const log = lp.log;
|
||
const String = lp.String;
|
||
|
||
const Document = @This();
|
||
|
||
pub const Proto = Node;
|
||
|
||
_type: Type,
|
||
_proto: *Node,
|
||
_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,
|
||
// Track IDs that were removed from the map - they might have duplicates in the tree
|
||
_removed_ids: std.StringHashMapUnmanaged(void) = .empty,
|
||
_active_element: ?*Element = null,
|
||
_style_sheets: ?*StyleSheetList = null,
|
||
_implementation: ?*DOMImplementation = null,
|
||
_fonts: ?*FontFaceSet = null,
|
||
_write_insertion_point: ?*Node = null,
|
||
_script_created_parser: ?Parser.Streaming = null,
|
||
_close_requested: bool = false,
|
||
_adopted_style_sheets: ?js.Object.Global = null,
|
||
_selection: Selection = .{ ._rc = .init(1) },
|
||
// Ordered stack of currently-showing popovers
|
||
_open_popovers: std.ArrayList(*Element) = .empty,
|
||
|
||
// https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#throw-on-dynamic-markup-insertion-counter
|
||
// Incremented during custom element reactions when parsing. When > 0,
|
||
// document.open/close/write/writeln must throw InvalidStateError.
|
||
_throw_on_dynamic_markup_insertion_counter: u32 = 0,
|
||
|
||
_on_selectionchange: ?js.Function.Global = null,
|
||
|
||
pub fn getOnSelectionChange(self: *Document) ?js.Function.Global {
|
||
return self._on_selectionchange;
|
||
}
|
||
|
||
pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void {
|
||
if (listener) |listen| {
|
||
self._on_selectionchange = try listen.persistWithThis(self);
|
||
} else {
|
||
self._on_selectionchange = null;
|
||
}
|
||
}
|
||
|
||
// Stored in the frame's attribute-listener map (like element and ShadowRoot
|
||
// property handlers), which the dispatch propagation path consults for any
|
||
// event target.
|
||
pub fn getOnClick(self: *Document, frame: *Frame) ?js.Function.Global {
|
||
return (self._frame orelse frame)._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onclick });
|
||
}
|
||
|
||
pub fn setOnClick(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void {
|
||
const owner = self._frame orelse frame;
|
||
if (Window.getFunctionFromSetter(setter)) |cb| {
|
||
try owner._event_target_attr_listeners.put(owner.arena, .{ .target = self.asEventTarget(), .handler = .onclick }, cb);
|
||
} else {
|
||
_ = owner._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = .onclick });
|
||
}
|
||
}
|
||
|
||
pub const Type = union(enum) {
|
||
generic,
|
||
html: *HTMLDocument,
|
||
xml: *XMLDocument,
|
||
};
|
||
|
||
pub fn is(self: *Document, comptime T: type) ?*T {
|
||
switch (self._type) {
|
||
.html => |html| {
|
||
if (T == HTMLDocument) {
|
||
return html;
|
||
}
|
||
},
|
||
.xml => |xml| {
|
||
if (T == XMLDocument) {
|
||
return xml;
|
||
}
|
||
},
|
||
.generic => {},
|
||
}
|
||
return null;
|
||
}
|
||
|
||
pub fn as(self: *Document, comptime T: type) *T {
|
||
return self.is(T).?;
|
||
}
|
||
|
||
pub fn asNode(self: *Document) *Node {
|
||
return self._proto;
|
||
}
|
||
|
||
pub fn asEventTarget(self: *Document) *@import("EventTarget.zig") {
|
||
return self._proto.asEventTarget();
|
||
}
|
||
|
||
pub fn getURL(self: *const Document, frame: *const Frame) [:0]const u8 {
|
||
return self._url orelse (self._frame orelse frame).url;
|
||
}
|
||
|
||
pub fn getLocation(self: *const Document) ?*Location {
|
||
if (self._type != .html) return null;
|
||
const doc_frame = self._frame orelse return null;
|
||
return doc_frame.window._location;
|
||
}
|
||
|
||
pub fn setLocation(self: *Document, url: [:0]const u8) !void {
|
||
if (self._type != .html) return;
|
||
const frame = self._frame orelse return;
|
||
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.
|
||
pub fn isQuirksMode(self: *const Document) bool {
|
||
if (self._type != .html) {
|
||
return false;
|
||
}
|
||
var it = self._proto.childrenIterator();
|
||
while (it.next()) |child| {
|
||
if (child._type == .document_type) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
pub fn getCompatMode(self: *const Document) []const u8 {
|
||
return if (self.isQuirksMode()) "BackCompat" else "CSS1Compat";
|
||
}
|
||
|
||
// document.lastModified: the response's Last-Modified header in local time,
|
||
// "MM/DD/YYYY hh:mm:ss", defaulting to the current time.
|
||
pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 {
|
||
const dt = @import("../../datetime.zig");
|
||
|
||
const timestamp = blk: {
|
||
if (self._frame) |owner| {
|
||
for (owner._http_headers.items) |header| {
|
||
if (std.ascii.eqlIgnoreCase(header.name, "last-modified")) {
|
||
if (dt.DateTime.parse(header.value, .rfc822)) |parsed| {
|
||
break :blk parsed.unix(.seconds);
|
||
} else |_| {}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// localTime is signed: a parsed Last-Modified can predate the epoch
|
||
break :blk @as(i64, @intCast(lp.datetime.timestamp(.real)));
|
||
};
|
||
|
||
const tm = try dt.localTime(timestamp);
|
||
return std.fmt.allocPrint(frame.local_arena, "{d:0>2}/{d:0>2}/{d} {d:0>2}:{d:0>2}:{d:0>2}", .{
|
||
@as(u32, @intCast(tm.tm_mon + 1)),
|
||
@as(u32, @intCast(tm.tm_mday)),
|
||
tm.tm_year + 1900,
|
||
@as(u32, @intCast(tm.tm_hour)),
|
||
@as(u32, @intCast(tm.tm_min)),
|
||
@as(u32, @intCast(tm.tm_sec)),
|
||
});
|
||
}
|
||
|
||
pub fn getReferrer(self: *const Document) []const u8 {
|
||
const frame = self._frame orelse return "";
|
||
return frame._referrer orelse "";
|
||
}
|
||
|
||
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;
|
||
}
|
||
return switch (self._type) {
|
||
.html => "text/html",
|
||
.xml => "application/xml",
|
||
.generic => "application/xml",
|
||
};
|
||
}
|
||
|
||
pub fn getDomain(self: *const Document, frame: *const Frame) []const u8 {
|
||
const doc_frame = self._frame orelse frame;
|
||
|
||
// When document.domain has been set, the effective domain is encoded in
|
||
// the origin key with a leading '!' marker. The key is a "!scheme://host"
|
||
// serialization with the port already dropped, so the host *is* the
|
||
// effective domain.
|
||
const key = doc_frame.js.origin.key;
|
||
if (key.len != 0 and key[0] == '!') {
|
||
return URL.getHost(key[1..]);
|
||
}
|
||
|
||
// Derive from the origin, not the URL: an about:blank child inherits the
|
||
// parent origin while keeping url == "about:blank". Opaque origin => "".
|
||
const origin = doc_frame.origin orelse return "";
|
||
return URL.getOriginHostname(origin);
|
||
}
|
||
|
||
pub fn setDomain(self: *Document, value: []const u8) !void {
|
||
// e.g. (new Document().domain = '')
|
||
const doc_frame = self._frame orelse return error.SecurityError;
|
||
const origin = doc_frame.origin orelse return error.SecurityError;
|
||
|
||
const arena = doc_frame.local_arena;
|
||
const requested = if (idna.needsAscii(value)) try idna.toAscii(arena, value) else value;
|
||
|
||
// Validate against the current effective domain. Once relaxed,
|
||
// document.domain can only broaden further.
|
||
const base = self.getDomain(doc_frame);
|
||
if (isRelaxableTo(base, requested) == false) {
|
||
return error.SecurityError;
|
||
}
|
||
|
||
// When the domain is explicitly set, it only matches other explicitly set
|
||
// domains. We do this by prepending a '!' to the origin, so that it can
|
||
// only ever match another explicitly set domain.
|
||
// The scheme is preserved (http and https must never collide) and the
|
||
// port is dropped, per spec.
|
||
const scheme_end = (std.mem.indexOf(u8, origin, "://") orelse return error.SecurityError) + 3;
|
||
const key = try std.mem.concat(arena, u8, &.{ "!", origin[0..scheme_end], requested });
|
||
try doc_frame.js.setOrigin(key);
|
||
}
|
||
|
||
// A cookie-averse document (no browsing context: createHTMLDocument,
|
||
// DOMParser, XHR documents) reads cookies as the empty string and ignores
|
||
// writes.
|
||
fn isCookieAverse(self: *const Document, frame: *const Frame) bool {
|
||
const doc_frame = self._frame orelse return true;
|
||
return doc_frame.document != self and frame.document != self;
|
||
}
|
||
|
||
pub fn getCookie(self: *Document, frame: *Frame) ![]const u8 {
|
||
if (self.isCookieAverse(frame)) {
|
||
return "";
|
||
}
|
||
var aw: std.Io.Writer.Allocating = .init(frame.local_arena);
|
||
try frame._session.cookie_jar.forRequest(frame.url, &aw.writer, .{
|
||
.is_http = false,
|
||
.is_navigation = true,
|
||
});
|
||
return aw.written();
|
||
}
|
||
|
||
pub fn setCookie(self: *Document, cookie_str: []const u8, frame: *Frame) ![]const u8 {
|
||
if (self.isCookieAverse(frame)) {
|
||
return cookie_str;
|
||
}
|
||
// we use the cookie jar's allocator to parse the cookie because it
|
||
// outlives the frame's arena.
|
||
const Cookie = @import("storage/Cookie.zig");
|
||
const c = Cookie.parse(frame._session.cookie_jar.allocator, frame.url, cookie_str) catch {
|
||
// Invalid cookies should be silently ignored, not throw errors
|
||
return "";
|
||
};
|
||
if (c.http_only) {
|
||
c.deinit();
|
||
return ""; // HttpOnly cookies cannot be set from JS
|
||
}
|
||
try frame._session.cookie_jar.add(c, lp.datetime.timestamp(.real), false);
|
||
return cookie_str;
|
||
}
|
||
|
||
// Returns true if the requested domain is valid for the given host
|
||
fn isRelaxableTo(host: []const u8, requested: []const u8) bool {
|
||
if (requested.len == 0) {
|
||
return false;
|
||
}
|
||
|
||
// Pure opt-in: relaxing to your own host is always allowed (and it's the
|
||
// only valid value for IPs)
|
||
if (std.mem.eql(u8, host, requested)) {
|
||
return true;
|
||
}
|
||
|
||
// request must be a subset of host, so it must be smaller
|
||
if (host.len <= requested.len) {
|
||
return false;
|
||
}
|
||
|
||
if (host[host.len - requested.len - 1] != '.') {
|
||
return false;
|
||
}
|
||
|
||
if (std.mem.endsWith(u8, host, requested) == false) {
|
||
return false;
|
||
}
|
||
|
||
// it can't be a bare TLD, "com"
|
||
if (std.mem.indexOfScalar(u8, requested, '.') == null) {
|
||
return false;
|
||
}
|
||
|
||
// and it can't be a public suffix (e.g. "gov.uk")
|
||
return public_suffix_list.lookup(requested) == false;
|
||
}
|
||
|
||
const CreateElementOptions = struct {
|
||
is: ?[]const u8 = null,
|
||
};
|
||
|
||
pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, frame: *Frame) !*Element {
|
||
try validateElementName(name);
|
||
const ns: Element.Namespace, const normalized_name = blk: {
|
||
if (self._type == .html) {
|
||
break :blk .{ .html, std.ascii.lowerString(&frame.buf, name) };
|
||
}
|
||
// Generic and XML documents create elements with null namespace
|
||
break :blk .{ .null, name };
|
||
};
|
||
// HTML documents are case-insensitive - lowercase the tag name
|
||
|
||
const node = try Frame.node_factory.createElementNS(frame, ns, normalized_name, null);
|
||
const element = node.as(Element);
|
||
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
|
||
const options = options_ orelse return element;
|
||
if (options.is) |is_value| {
|
||
try element.setAttribute(comptime .wrap("is"), .wrap(is_value), frame);
|
||
try Element.Html.Custom.checkAndAttachBuiltIn(element, frame);
|
||
}
|
||
|
||
return element;
|
||
}
|
||
|
||
pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, frame: *Frame) !*Element {
|
||
_ = try validateAndExtract(namespace, name, .element);
|
||
const ns = Element.Namespace.parse(namespace);
|
||
// Per spec, createElementNS does NOT lowercase (unlike createElement).
|
||
const node = try Frame.node_factory.createElementNS(frame, ns, name, null);
|
||
|
||
// Store original URI for unknown namespaces so lookupNamespaceURI can return it
|
||
if (ns == .unknown) {
|
||
if (namespace) |uri| {
|
||
const duped = try frame.dupeString(uri);
|
||
try frame._element_namespace_uris.put(frame.arena, node.as(Element), duped);
|
||
}
|
||
}
|
||
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
return node.as(Element);
|
||
}
|
||
|
||
pub fn createAttribute(_: *const Document, name: String.Global, frame: *Frame) !?*Element.Attribute {
|
||
try Element.Attribute.validateAttributeName(name.str);
|
||
return frame._factory.node(Element.Attribute{
|
||
._name = name.str,
|
||
._value = String.empty,
|
||
._element = null,
|
||
});
|
||
}
|
||
|
||
pub fn createAttributeNS(_: *const Document, namespace: []const u8, name: String.Global, frame: *Frame) !?*Element.Attribute {
|
||
if (std.mem.eql(u8, namespace, "http://www.w3.org/1999/xhtml") == false) {
|
||
log.warn(.not_implemented, "document.createAttributeNS", .{ .namespace = namespace });
|
||
}
|
||
|
||
try Element.Attribute.validateAttributeName(name.str);
|
||
return frame._factory.node(Element.Attribute{
|
||
._name = name.str,
|
||
._value = String.empty,
|
||
._element = null,
|
||
});
|
||
}
|
||
|
||
pub fn getElementById(self: *Document, id: []const u8, frame: *Frame) ?*Element {
|
||
if (id.len == 0) {
|
||
return null;
|
||
}
|
||
|
||
if (self._elements_by_id.get(id)) |element| {
|
||
return element;
|
||
}
|
||
|
||
//ID was removed but might have duplicates
|
||
if (self._removed_ids.remove(id)) {
|
||
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
|
||
while (tw.next()) |el| {
|
||
const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue;
|
||
if (std.mem.eql(u8, element_id, id)) {
|
||
// we ignore this error to keep getElementById easy to call
|
||
// if it really failed, then we're out of memory and nothing's
|
||
// going to work like it should anyways.
|
||
const owned_id = frame.dupeString(id) catch return null;
|
||
self._elements_by_id.put(frame.arena, owned_id, el) catch return null;
|
||
return el;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
pub fn getElementsByTagName(self: *Document, tag_name: []const u8, frame: *Frame) !Node.GetElementsByTagNameResult {
|
||
return self.asNode().getElementsByTagName(tag_name, frame);
|
||
}
|
||
|
||
pub fn getElementsByTagNameNS(self: *Document, namespace: ?[]const u8, local_name: []const u8, frame: *Frame) !collections.NodeLive(.tag_name_ns) {
|
||
return self.asNode().getElementsByTagNameNS(namespace, local_name, frame);
|
||
}
|
||
|
||
pub fn getElementsByClassName(self: *Document, class_name: []const u8, frame: *Frame) !collections.NodeLive(.class_name) {
|
||
return self.asNode().getElementsByClassName(class_name, frame);
|
||
}
|
||
|
||
pub fn getElementsByName(self: *Document, name: []const u8, frame: *Frame) !collections.NodeLive(.name) {
|
||
const arena = frame.arena;
|
||
const filter = try arena.dupe(u8, name);
|
||
return collections.NodeLive(.name).init(self.asNode(), filter, frame);
|
||
}
|
||
|
||
pub fn getChildren(self: *Document, frame: *Frame) !collections.NodeLive(.child_elements) {
|
||
return collections.NodeLive(.child_elements).init(self.asNode(), {}, frame);
|
||
}
|
||
|
||
pub fn getDocumentElement(self: *Document) ?*Element {
|
||
var child = self.asNode().firstChild();
|
||
while (child) |node| {
|
||
if (node.is(Element)) |el| {
|
||
return el;
|
||
}
|
||
child = node.nextSibling();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
pub fn getSelection(self: *Document) *Selection {
|
||
return &self._selection;
|
||
}
|
||
|
||
pub fn querySelector(self: *Document, input: String, frame: *Frame) !?*Element {
|
||
return Selector.querySelector(self.asNode(), input.str(), frame) catch |err| Selector.mapErrorToDOM(err);
|
||
}
|
||
|
||
pub fn querySelectorAll(self: *Document, input: String, frame: *Frame) !*Selector.List {
|
||
return Selector.querySelectorAll(self.asNode(), input.str(), frame) catch |err| Selector.mapErrorToDOM(err);
|
||
}
|
||
|
||
pub fn getImplementation(self: *Document, frame: *Frame) !*DOMImplementation {
|
||
if (self._implementation) |impl| return impl;
|
||
const impl = try frame._factory.create(DOMImplementation{ ._document = self });
|
||
self._implementation = impl;
|
||
return impl;
|
||
}
|
||
|
||
pub fn createDocumentFragment(self: *Document, frame: *Frame) !*Node.DocumentFragment {
|
||
const frag = try Node.DocumentFragment.init(frame);
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(frag.asNode(), self);
|
||
}
|
||
return frag;
|
||
}
|
||
|
||
pub fn createComment(self: *Document, data: []const u8, frame: *Frame) !*Node {
|
||
const node = try Frame.node_factory.createComment(frame, data);
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
pub fn createTextNode(self: *Document, data: []const u8, frame: *Frame) !*Node {
|
||
const node = try Frame.node_factory.createTextNode(frame, data);
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
pub fn createCDATASection(self: *Document, data: []const u8, frame: *Frame) !*Node {
|
||
const node = switch (self._type) {
|
||
.html => return error.NotSupported, // cannot create a CDataSection in an HTMLDocument
|
||
.xml => try Frame.node_factory.createCDATASection(frame, data),
|
||
.generic => try Frame.node_factory.createCDATASection(frame, data),
|
||
};
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
pub fn createProcessingInstruction(self: *Document, target: []const u8, data: []const u8, frame: *Frame) !*Node {
|
||
const node = try Frame.node_factory.createProcessingInstruction(frame, target, data);
|
||
// Track owner document if it's not the main document
|
||
if (self != frame.document) {
|
||
try frame.setNodeOwnerDocument(node, self);
|
||
}
|
||
return node;
|
||
}
|
||
|
||
const Range = @import("Range.zig");
|
||
pub fn createRange(self: *Document, frame: *Frame) !*Range {
|
||
return Range.initIn(self.asNode(), frame);
|
||
}
|
||
|
||
pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !*@import("Event.zig") {
|
||
const Event = @import("Event.zig");
|
||
if (event_type.len > 100) {
|
||
return error.NotSupported;
|
||
}
|
||
const normalized = std.ascii.lowerString(&frame.buf, event_type);
|
||
|
||
const event: *Event = blk: {
|
||
if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents") or std.mem.eql(u8, normalized, "svgevents")) {
|
||
break :blk try Event.init("", null, frame._page);
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "customevent")) {
|
||
const CustomEvent = @import("event/CustomEvent.zig");
|
||
break :blk (try CustomEvent.init("", null, frame._page)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "keyboardevent")) {
|
||
const KeyboardEvent = @import("event/KeyboardEvent.zig");
|
||
break :blk (try KeyboardEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "inputevent")) {
|
||
const InputEvent = @import("event/InputEvent.zig");
|
||
break :blk (try InputEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
|
||
const MouseEvent = @import("event/MouseEvent.zig");
|
||
break :blk (try MouseEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "dragevent")) {
|
||
const DragEvent = @import("event/DragEvent.zig");
|
||
break :blk (try DragEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "messageevent")) {
|
||
const MessageEvent = @import("event/MessageEvent.zig");
|
||
break :blk (try MessageEvent.init("", null, frame._page)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "hashchangeevent")) {
|
||
const HashChangeEvent = @import("event/HashChangeEvent.zig");
|
||
break :blk (try HashChangeEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) {
|
||
const UIEvent = @import("event/UIEvent.zig");
|
||
break :blk (try UIEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "focusevent")) {
|
||
const FocusEvent = @import("event/FocusEvent.zig");
|
||
break :blk (try FocusEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "textevent")) {
|
||
const TextEvent = @import("event/TextEvent.zig");
|
||
break :blk (try TextEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "compositionevent")) {
|
||
const CompositionEvent = @import("event/CompositionEvent.zig");
|
||
break :blk (try CompositionEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "beforeunloadevent")) {
|
||
const BeforeUnloadEvent = @import("event/BeforeUnloadEvent.zig");
|
||
break :blk (try BeforeUnloadEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "devicemotionevent")) {
|
||
const DeviceMotionEvent = @import("event/DeviceMotionEvent.zig");
|
||
break :blk (try DeviceMotionEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "deviceorientationevent")) {
|
||
const DeviceOrientationEvent = @import("event/DeviceOrientationEvent.zig");
|
||
break :blk (try DeviceOrientationEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "storageevent")) {
|
||
const StorageEvent = @import("event/StorageEvent.zig");
|
||
break :blk (try StorageEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
if (std.mem.eql(u8, normalized, "touchevent")) {
|
||
const TouchEvent = @import("event/TouchEvent.zig");
|
||
break :blk (try TouchEvent.init("", null, frame)).asEvent();
|
||
}
|
||
|
||
return error.NotSupported;
|
||
};
|
||
|
||
// createEvent returns an uninitialized event: dispatching it before one
|
||
// of the init*Event calls throws an InvalidStateError.
|
||
event._initialized = false;
|
||
return event;
|
||
}
|
||
|
||
pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMTreeWalker.FilterOpts, frame: *Frame) !*DOMTreeWalker {
|
||
return DOMTreeWalker.init(root, try whatToShow(what_to_show), filter, frame);
|
||
}
|
||
|
||
pub fn createNodeIterator(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMNodeIterator.FilterOpts, frame: *Frame) !*DOMNodeIterator {
|
||
return DOMNodeIterator.init(root, try whatToShow(what_to_show), filter, frame);
|
||
}
|
||
|
||
pub fn evaluate(
|
||
self: *Document,
|
||
expression: []const u8,
|
||
context_node: ?*Node,
|
||
resolver: ?js.Function,
|
||
result_type: ?u16,
|
||
result: ?*XPathResult,
|
||
frame: *Frame,
|
||
) !*XPathResult {
|
||
// resolver/result are no-ops in HTML mode (decision #2).
|
||
// Null/missing context_node falls back to the document — matches the
|
||
// polyfill (decision #2). Firefox throws TypeError on a *missing*
|
||
// arg, but the bridge can't distinguish "missing" from "explicit
|
||
// null" here, so polyfill parity wins for the ambiguity.
|
||
_ = resolver;
|
||
_ = result;
|
||
return XPathResult.fromExpression(
|
||
expression,
|
||
context_node orelse self.asNode(),
|
||
result_type orelse XPathResult.ANY_TYPE,
|
||
frame,
|
||
);
|
||
}
|
||
|
||
pub fn createExpression(
|
||
_: *const Document,
|
||
expression: []const u8,
|
||
resolver: ?js.Function,
|
||
frame: *Frame,
|
||
) !*XPathExpression {
|
||
_ = resolver;
|
||
return XPathExpression.init(expression, frame);
|
||
}
|
||
|
||
pub fn createNSResolver(_: *const Document, node: *Node) ?*Node {
|
||
return node;
|
||
}
|
||
|
||
fn whatToShow(value_: ?js.Value) !u32 {
|
||
const value = value_ orelse return 4294967295; // show all when undefined
|
||
if (value.isUndefined()) {
|
||
// undefined explicitly passed
|
||
return 4294967295;
|
||
}
|
||
|
||
if (value.isNull()) {
|
||
return 0;
|
||
}
|
||
|
||
return value.toZig(u32);
|
||
}
|
||
|
||
pub fn getReadyState(self: *const Document) []const u8 {
|
||
return @tagName(self._ready_state);
|
||
}
|
||
|
||
pub fn getActiveElement(self: *Document) ?*Element {
|
||
if (self._active_element) |el| {
|
||
// A focused element inside a shadow tree is exposed as its outermost
|
||
// host; one in a detached tree isn't exposed at all.
|
||
var candidate = el;
|
||
while (candidate.asNode().containingShadowRoot()) |shadow| {
|
||
candidate = shadow._host;
|
||
}
|
||
if (candidate.asNode().getRootNode(.{}) == self.asNode()) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
// Default to body if it exists
|
||
if (self.is(HTMLDocument)) |html_doc| {
|
||
if (html_doc.getBody()) |body| {
|
||
return body.asElement();
|
||
}
|
||
}
|
||
|
||
// Fallback to document element
|
||
return self.getDocumentElement();
|
||
}
|
||
|
||
pub fn getStyleSheets(self: *Document, frame: *Frame) !*StyleSheetList {
|
||
if (self._style_sheets) |sheets| {
|
||
return sheets;
|
||
}
|
||
const sheets = try StyleSheetList.init(frame);
|
||
self._style_sheets = sheets;
|
||
return sheets;
|
||
}
|
||
|
||
pub fn getFonts(self: *Document, frame: *Frame) !*FontFaceSet {
|
||
if (self._fonts) |fonts| {
|
||
return fonts;
|
||
}
|
||
const fonts = try FontFaceSet.init(frame);
|
||
fonts.acquireRef();
|
||
self._fonts = fonts;
|
||
return fonts;
|
||
}
|
||
|
||
pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node {
|
||
if (node._type == .document) {
|
||
return error.NotSupported;
|
||
}
|
||
if (node.is(Node.ShadowRoot) != null) {
|
||
return error.HierarchyError;
|
||
}
|
||
|
||
const old_owner = node.ownerDocument(frame) orelse frame.document;
|
||
|
||
if (node._parent) |parent| {
|
||
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
|
||
}
|
||
|
||
if (old_owner != self) {
|
||
try frame.adoptNodeTree(node, old_owner, self);
|
||
}
|
||
|
||
return node;
|
||
}
|
||
|
||
pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, frame: *Frame) !*Node {
|
||
if (node._type == .document) {
|
||
return error.NotSupported;
|
||
}
|
||
|
||
return node.cloneNode(deep_, frame);
|
||
}
|
||
|
||
pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||
try validateDocumentNodes(self, nodes, false);
|
||
|
||
const parent = self.asNode();
|
||
frame.domChanged();
|
||
const parent_is_connected = parent.isConnected();
|
||
|
||
for (nodes) |node_or_text| {
|
||
const child = try node_or_text.toNode(frame);
|
||
|
||
// DocumentFragments are special - append all their children
|
||
if (child.is(Node.DocumentFragment)) |_| {
|
||
try frame.appendAllChildren(child, parent);
|
||
continue;
|
||
}
|
||
|
||
var child_connected = false;
|
||
if (child._parent) |previous_parent| {
|
||
child_connected = child.isConnected();
|
||
frame.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
|
||
}
|
||
try frame.appendNode(parent, child, .{ .child_already_connected = child_connected });
|
||
}
|
||
}
|
||
|
||
pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||
try validateDocumentNodes(self, nodes, false);
|
||
|
||
const parent = self.asNode();
|
||
frame.domChanged();
|
||
const parent_is_connected = parent.isConnected();
|
||
|
||
var i = nodes.len;
|
||
while (i > 0) {
|
||
i -= 1;
|
||
const child = try nodes[i].toNode(frame);
|
||
|
||
// DocumentFragments are special - need to insert all their children
|
||
if (child.is(Node.DocumentFragment)) |frag| {
|
||
const first_child = parent.firstChild();
|
||
var frag_child = frag.asNode().lastChild();
|
||
while (frag_child) |fc| {
|
||
const prev = fc.previousSibling();
|
||
frame.removeNode(frag.asNode(), fc, .{ .will_be_reconnected = parent_is_connected });
|
||
if (first_child) |before| {
|
||
try frame.insertNodeRelative(parent, fc, .{ .before = before }, .{});
|
||
} else {
|
||
try frame.appendNode(parent, fc, .{});
|
||
}
|
||
frag_child = prev;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
var child_connected = false;
|
||
if (child._parent) |previous_parent| {
|
||
child_connected = child.isConnected();
|
||
frame.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
|
||
}
|
||
|
||
const first_child = parent.firstChild();
|
||
if (first_child) |before| {
|
||
try frame.insertNodeRelative(parent, child, .{ .before = before }, .{ .child_already_connected = child_connected });
|
||
} else {
|
||
try frame.appendNode(parent, child, .{ .child_already_connected = child_connected });
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||
try validateDocumentNodes(self, nodes, false);
|
||
return self.asNode().replaceChildren(nodes, frame);
|
||
}
|
||
|
||
pub fn moveBefore(self: *Document, node: js.Value, child: js.Value, frame: *Frame) !void {
|
||
return self.asNode().moveBefore(node, child, frame);
|
||
}
|
||
|
||
pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Element {
|
||
return self.elementFromPointImpl(x, y, false, frame);
|
||
}
|
||
|
||
// The faux layout gives most elements no useful horizontal extent, so
|
||
// viewport-relative hit-testing (WebDriver scroll actions) matches on the
|
||
// vertical axis only.
|
||
pub fn elementFromVerticalPoint(self: *Document, y: f64, frame: *Frame) !?*Element {
|
||
return self.elementFromPointImpl(0, y, true, frame);
|
||
}
|
||
|
||
fn elementFromPointImpl(self: *Document, x: f64, y: f64, ignore_x: bool, frame: *Frame) !?*Element {
|
||
// DFS in document order; topmost = last visited element whose rect contains (x, y).
|
||
//
|
||
// Faux-layout shortcut: rect.top is calculateDocumentPosition × 5, which is
|
||
// monotonically increasing in document order. So we maintain a running
|
||
// preorder counter instead of calling calculateDocumentPosition per node
|
||
// (which itself is O(N)). Once the counter's y passes the query y, no
|
||
// later element can contain the point, and we can return.
|
||
//
|
||
// We also share a single VisibilityCache across all elements so the
|
||
// ancestor-walk inside isHidden gets amortized.
|
||
var topmost: ?*Element = null;
|
||
|
||
const root = self.asNode();
|
||
var stack: std.ArrayList(*Node) = .empty;
|
||
try stack.append(frame.local_arena, root);
|
||
|
||
var visibility_cache: Element.VisibilityCache = .{};
|
||
var preorder_index: f64 = 0;
|
||
|
||
while (stack.items.len > 0) {
|
||
const node = stack.pop() orelse break;
|
||
const pos = preorder_index * 5.0;
|
||
|
||
if (pos > y) {
|
||
// Monotonic: no later element has top <= y, so none can contain (x, y).
|
||
return topmost;
|
||
}
|
||
|
||
preorder_index += 1;
|
||
if (node.is(Element)) |element| {
|
||
if (element.checkVisibilityCached(&visibility_cache, frame)) {
|
||
const dims = element.getElementDimensions(frame);
|
||
// x and y both come from preorder position in our faux layout.
|
||
const left = pos;
|
||
const top = pos;
|
||
const right = pos + dims.width;
|
||
const bottom = pos + dims.height;
|
||
const x_contained = ignore_x or (x >= left and x <= right);
|
||
if (x_contained and y >= top and y <= bottom) {
|
||
topmost = element;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add children to stack in reverse order so we process them in document order
|
||
var child = node.lastChild();
|
||
while (child) |c| {
|
||
try stack.append(frame.local_arena, c);
|
||
child = c.previousSibling();
|
||
}
|
||
}
|
||
|
||
return topmost;
|
||
}
|
||
|
||
pub fn elementsFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) ![]const *Element {
|
||
// Get topmost element
|
||
var current: ?*Element = (try self.elementFromPoint(x, y, frame)) orelse return &.{};
|
||
var result: std.ArrayList(*Element) = .empty;
|
||
while (current) |el| {
|
||
try result.append(frame.local_arena, el);
|
||
current = el.parentElement();
|
||
}
|
||
return result.items;
|
||
}
|
||
|
||
pub fn getDocType(self: *Document) ?*Node {
|
||
var tw = @import("TreeWalker.zig").Full.init(self.asNode(), .{});
|
||
while (tw.next()) |node| {
|
||
if (node._type == .document_type) {
|
||
return node;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// document.write is complicated and works differently based on the state of
|
||
// parsing. But, generally, it's supposed to be additive/streaming. Multiple
|
||
// document.writes are parsed a single unit. Well, that causes issues with
|
||
// html5ever if we're trying to parse 1 document which is really many. So we
|
||
// try to detect "new" documents. (This is particularly problematic because we
|
||
// don't have proper frame support, so document.write into a frame can get
|
||
// sent to the main document (instead of the frame document)...and it's completely
|
||
// reasonable for 2 frames to document.write("<html>...</html>") into their own
|
||
// frame.
|
||
fn looksLikeNewDocument(html: []const u8) bool {
|
||
const trimmed = std.mem.trimStart(u8, html, &std.ascii.whitespace);
|
||
return std.ascii.startsWithIgnoreCase(trimmed, "<!DOCTYPE") or
|
||
std.ascii.startsWithIgnoreCase(trimmed, "<html");
|
||
}
|
||
|
||
pub fn write(self: *Document, text: []const []const u8, frame: *Frame) !void {
|
||
return self.writeInternal(text, false, frame);
|
||
}
|
||
|
||
// https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html#dom-document-writeln
|
||
// `writeln(...text)` runs the document write steps with `text` followed by a
|
||
// U+000A LINE FEED character.
|
||
pub fn writeln(self: *Document, text: []const []const u8, frame: *Frame) !void {
|
||
return self.writeInternal(text, true, frame);
|
||
}
|
||
|
||
fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool, call_frame: *Frame) !void {
|
||
// document.write acts on this document's own frame, which isn't necessarily
|
||
// the calling frame — e.g. a parent frame writing into an iframe's document.
|
||
// The markup (and any scripts it contains) must be parsed and run in that
|
||
// document's context, not the caller's.
|
||
const frame = self._frame orelse call_frame;
|
||
|
||
if (self._type == .xml) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
const html = blk: {
|
||
var joined: std.ArrayList(u8) = .empty;
|
||
for (text) |str| {
|
||
// Scratch buffer, consumed synchronously below. Keep it on the
|
||
// active (calling) frame's call_arena: a script run by the parse
|
||
// could reset the document frame's call_arena underfoot.
|
||
try joined.appendSlice(call_frame.call_arena, str);
|
||
}
|
||
if (append_newline) {
|
||
try joined.append(call_frame.call_arena, '\n');
|
||
}
|
||
break :blk joined.items;
|
||
};
|
||
|
||
if (self._current_script == null or frame._load_state != .parsing) {
|
||
if (self._script_created_parser == null or looksLikeNewDocument(html)) {
|
||
_ = try self.open(frame);
|
||
}
|
||
|
||
if (html.len > 0) {
|
||
if (self._script_created_parser) |*parser| {
|
||
parser.read(html) catch |err| {
|
||
log.warn(.dom, "document.write parser error", .{ .err = err });
|
||
// html5ever's handle was destroyed inside read(), but the
|
||
// pending text buffer (if any) still wants to land on its
|
||
// text node's _data — flushPendingText doesn't depend on
|
||
// the handle, so attempt a final flush before dropping.
|
||
parser.parser.flushPendingText() catch |flush_err| {
|
||
log.warn(.dom, "flush after parser panic", .{ .err = flush_err });
|
||
};
|
||
self._script_created_parser = null;
|
||
self._close_requested = false;
|
||
};
|
||
}
|
||
}
|
||
|
||
if (self._close_requested) {
|
||
// document.close was executed during a document.write. We couldn't
|
||
// execute that during the write, but we can now.
|
||
if (self._script_created_parser) |*parser| {
|
||
if (parser.feeding == false) {
|
||
try self.finishScriptCreatedParser(frame);
|
||
}
|
||
} else {
|
||
self._close_requested = false;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Inline script during parsing
|
||
const script = self._current_script.?;
|
||
const parent = script.asNode().parentNode() orelse return;
|
||
|
||
// Our implementation is hacky. We'll write to a DocumentFragment, then
|
||
// append its children.
|
||
const fragment = try Node.DocumentFragment.init(frame);
|
||
const fragment_node = fragment.asNode();
|
||
|
||
const previous_parse_mode = frame._parse_mode;
|
||
frame._parse_mode = .document_write;
|
||
defer frame._parse_mode = previous_parse_mode;
|
||
|
||
const arena = try frame.getArena(.medium, "Document.write");
|
||
defer arena.release();
|
||
|
||
var parser = Parser.init(arena.allocator(), fragment_node, frame, .{ .allow_declarative_shadow = true });
|
||
parser.parseFragment(html);
|
||
|
||
// Extract children from wrapper HTML element (html5ever wraps fragments)
|
||
// https://github.com/servo/html5ever/issues/583
|
||
const first = fragment_node.firstChild() orelse return;
|
||
|
||
// Collect all children to insert (to avoid iterator invalidation)
|
||
var children_to_insert: std.ArrayList(*Node) = .empty;
|
||
|
||
var it = if (first.is(Element.Html.Html) == null) fragment_node.childrenIterator() else first.childrenIterator();
|
||
while (it.next()) |child| {
|
||
try children_to_insert.append(arena.allocator(), child);
|
||
}
|
||
|
||
if (children_to_insert.items.len == 0) {
|
||
return;
|
||
}
|
||
|
||
// Determine insertion point:
|
||
// - If _write_insertion_point is set and still parented correctly, continue from there
|
||
// - Otherwise, start after the script (first write, or previous insertion point was removed)
|
||
// parseFragment above can synchronously execute a parser-blocking script
|
||
// (e.g. <script src=...> with from_parser=true). That script's side
|
||
// effects can detach `script` from `parent` — for instance, by writing
|
||
// to parent.innerHTML — leaving us nowhere sensible to splice in.
|
||
var insert_after: ?*Node = blk: {
|
||
if (self._write_insertion_point) |wip| {
|
||
if (wip._parent == parent) {
|
||
break :blk wip;
|
||
}
|
||
}
|
||
if (script.asNode()._parent == parent) {
|
||
break :blk script.asNode();
|
||
}
|
||
return;
|
||
};
|
||
|
||
for (children_to_insert.items) |child| {
|
||
// Clear parent pointer (child is currently parented to fragment/HTML wrapper)
|
||
child._parent = null;
|
||
try frame.insertNodeRelative(parent, child, .{ .after = insert_after.? }, .{});
|
||
insert_after = child;
|
||
}
|
||
|
||
frame.domChanged();
|
||
self._write_insertion_point = children_to_insert.getLast();
|
||
}
|
||
|
||
pub fn open(self: *Document, call_frame: *Frame) !*Document {
|
||
const frame = self._frame orelse call_frame;
|
||
|
||
if (self._type == .xml) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
if (frame._load_state == .parsing) {
|
||
return self;
|
||
}
|
||
|
||
if (self._script_created_parser != null) {
|
||
return self;
|
||
}
|
||
|
||
// If we aren't parsing, then open clears the document.
|
||
const doc_node = self.asNode();
|
||
|
||
{
|
||
// Remove all children from document
|
||
var it = doc_node.childrenIterator();
|
||
while (it.next()) |child| {
|
||
frame.removeNode(doc_node, child, .{ .will_be_reconnected = false });
|
||
}
|
||
}
|
||
|
||
// reset the document
|
||
self._elements_by_id.clearAndFree(frame.arena);
|
||
self._active_element = null;
|
||
self._open_popovers = .empty;
|
||
self._style_sheets = null;
|
||
self._implementation = null;
|
||
self._ready_state = .loading;
|
||
|
||
self._script_created_parser = Parser.Streaming.init(frame.arena, doc_node, frame, .{ .allow_declarative_shadow = true });
|
||
try self._script_created_parser.?.start();
|
||
frame._parse_mode = .document;
|
||
|
||
return self;
|
||
}
|
||
|
||
pub fn close(self: *Document, call_frame: *Frame) !void {
|
||
const frame = self._frame orelse call_frame;
|
||
|
||
if (self._type == .xml) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
if (self._throw_on_dynamic_markup_insertion_counter > 0) {
|
||
return error.InvalidStateError;
|
||
}
|
||
|
||
if (self._script_created_parser) |*parser| {
|
||
if (parser.feeding) {
|
||
// we're currently in a document.write, we cannot close. We flag
|
||
// the close and process it at the next safe spot.
|
||
self._close_requested = true;
|
||
return;
|
||
}
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
try self.finishScriptCreatedParser(frame);
|
||
}
|
||
|
||
fn finishScriptCreatedParser(self: *Document, frame: *Frame) !void {
|
||
self._close_requested = false;
|
||
|
||
// done() finishes html5ever's handle and runs the final flushPendingText.
|
||
// Even if flushPendingText errors, the handle is already finished and we
|
||
// must not retain the Streaming — defer so the error path also drops it.
|
||
// (Streaming.done nulls its own handle, so dropping the struct is safe.)
|
||
defer self._script_created_parser = null;
|
||
try self._script_created_parser.?.done();
|
||
|
||
// The write'd markup is fully parsed; run any deferred scripts it produced
|
||
// (e.g. inline modules) before firing the load event. This frame's initial
|
||
// parse may never have set static_scripts_done (e.g. a freshly-loaded
|
||
// iframe written into via document.write), so we can't rely on it.
|
||
frame._script_manager.base.scriptCreatedParseDone();
|
||
|
||
frame.documentIsComplete();
|
||
}
|
||
|
||
pub fn getFirstElementChild(self: *Document) ?*Element {
|
||
var it = self.asNode().childrenIterator();
|
||
while (it.next()) |child| {
|
||
if (child.is(Element)) |el| {
|
||
return el;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
pub fn getLastElementChild(self: *Document) ?*Element {
|
||
var maybe_child = self.asNode().lastChild();
|
||
while (maybe_child) |child| {
|
||
if (child.is(Element)) |el| {
|
||
return el;
|
||
}
|
||
maybe_child = child.previousSibling();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
pub fn getChildElementCount(self: *Document) u32 {
|
||
var i: u32 = 0;
|
||
var it = self.asNode().childrenIterator();
|
||
while (it.next()) |child| {
|
||
if (child.is(Element) != null) {
|
||
i += 1;
|
||
}
|
||
}
|
||
return i;
|
||
}
|
||
|
||
pub fn getAdoptedStyleSheets(self: *Document, frame: *Frame) !js.Object.Global {
|
||
if (self._adopted_style_sheets) |ass| {
|
||
return ass;
|
||
}
|
||
const js_arr = frame.js.local.?.newArray(0);
|
||
const js_obj = js_arr.toObject();
|
||
self._adopted_style_sheets = try js_obj.persist();
|
||
return self._adopted_style_sheets.?;
|
||
}
|
||
|
||
pub fn hasFocus(_: *Document) bool {
|
||
log.debug(.not_implemented, "Document.hasFocus", .{});
|
||
return true;
|
||
}
|
||
|
||
pub fn setAdoptedStyleSheets(self: *Document, sheets: js.Object) !void {
|
||
self._adopted_style_sheets = try sheets.persist();
|
||
}
|
||
|
||
// Validates that nodes can be inserted into a Document, respecting Document constraints:
|
||
// - At most one Element child
|
||
// - At most one DocumentType child
|
||
// - No Document, Attribute, or Text nodes
|
||
// - Only Element, DocumentType, Comment, and ProcessingInstruction are allowed
|
||
// When replacing=true, existing children are not counted (for replaceChildren)
|
||
fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, comptime replacing: bool) !void {
|
||
const parent = self.asNode();
|
||
|
||
// Check existing elements and doctypes (unless we're replacing all children)
|
||
var has_element = false;
|
||
var has_doctype = false;
|
||
|
||
if (!replacing) {
|
||
var it = parent.childrenIterator();
|
||
while (it.next()) |child| {
|
||
if (child._type == .element) {
|
||
has_element = true;
|
||
} else if (child._type == .document_type) {
|
||
has_doctype = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Validate new nodes
|
||
for (nodes) |node_or_text| {
|
||
switch (node_or_text) {
|
||
.text => {
|
||
// Text nodes are not allowed as direct children of Document
|
||
return error.HierarchyError;
|
||
},
|
||
.node => |child| {
|
||
// Check if it's a DocumentFragment - need to validate its children
|
||
if (child.is(Node.DocumentFragment)) |frag| {
|
||
var frag_it = frag.asNode().childrenIterator();
|
||
while (frag_it.next()) |frag_child| {
|
||
// Document can only contain: Element, DocumentType, Comment, ProcessingInstruction
|
||
switch (frag_child._type) {
|
||
.element => {
|
||
if (has_element) {
|
||
return error.HierarchyError;
|
||
}
|
||
has_element = true;
|
||
},
|
||
.document_type => {
|
||
if (has_doctype) {
|
||
return error.HierarchyError;
|
||
}
|
||
if (has_element) {
|
||
// Doctype cannot be inserted if document already has an element
|
||
return error.HierarchyError;
|
||
}
|
||
has_doctype = true;
|
||
},
|
||
.cdata => switch (frag_child.subtype(Node.CData)._type) {
|
||
.comment, .processing_instruction => {}, // Allowed
|
||
.text, .cdata_section => return error.HierarchyError, // Not allowed in Document
|
||
},
|
||
.document, .attribute, .document_fragment => return error.HierarchyError,
|
||
}
|
||
}
|
||
} else {
|
||
// Validate node type for direct insertion
|
||
switch (child._type) {
|
||
.element => {
|
||
if (has_element) {
|
||
return error.HierarchyError;
|
||
}
|
||
has_element = true;
|
||
},
|
||
.document_type => {
|
||
if (has_doctype) {
|
||
return error.HierarchyError;
|
||
}
|
||
if (has_element) {
|
||
// Doctype cannot be inserted if document already has an element
|
||
return error.HierarchyError;
|
||
}
|
||
has_doctype = true;
|
||
},
|
||
.cdata => switch (child.subtype(Node.CData)._type) {
|
||
.comment, .processing_instruction => {}, // Allowed
|
||
.text, .cdata_section => return error.HierarchyError, // Not allowed in Document
|
||
},
|
||
.document, .attribute, .document_fragment => return error.HierarchyError,
|
||
}
|
||
}
|
||
|
||
// Check for cycles
|
||
if (child.contains(parent)) {
|
||
return error.HierarchyError;
|
||
}
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// DOM §1.4 "Name validation" productions.
|
||
|
||
pub fn isValidElementLocalName(name: []const u8) bool {
|
||
if (name.len == 0) {
|
||
return false;
|
||
}
|
||
if (std.ascii.isAlphabetic(name[0])) {
|
||
// Names the HTML parser can construct: anything except ASCII
|
||
// whitespace, NUL, '/' or '>'.
|
||
for (name[1..]) |c| {
|
||
switch (c) {
|
||
'\t', '\n', 0x0C, '\r', ' ', 0, '/', '>' => return false,
|
||
else => {},
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
// Otherwise the first code point must be ':', '_' or beyond ASCII, and
|
||
// the rest restricted to alphanumerics, '-', '.', ':', '_' or non-ASCII.
|
||
if (name[0] != ':' and name[0] != '_' and name[0] < 0x80) {
|
||
return false;
|
||
}
|
||
for (name[1..]) |c| {
|
||
const valid = std.ascii.isAlphanumeric(c) or
|
||
c == '-' or c == '.' or c == ':' or c == '_' or c >= 0x80;
|
||
if (!valid) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
pub fn isValidNamespacePrefix(prefix: []const u8) bool {
|
||
if (prefix.len == 0) {
|
||
return false;
|
||
}
|
||
for (prefix) |c| {
|
||
switch (c) {
|
||
'\t', '\n', 0x0C, '\r', ' ', 0, '/', '>' => return false,
|
||
else => {},
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
pub fn isValidAttributeLocalName(name: []const u8) bool {
|
||
if (name.len == 0) {
|
||
return false;
|
||
}
|
||
for (name) |c| {
|
||
switch (c) {
|
||
'\t', '\n', 0x0C, '\r', ' ', 0, '/', '=', '>' => return false,
|
||
else => {},
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
fn validateElementName(name: []const u8) !void {
|
||
if (!isValidElementLocalName(name)) {
|
||
return error.InvalidCharacterError;
|
||
}
|
||
}
|
||
|
||
pub const ValidatedName = struct {
|
||
prefix: ?[]const u8,
|
||
local_name: []const u8,
|
||
namespace: ?[]const u8,
|
||
};
|
||
|
||
// The DOM spec's "validate and extract a namespace and qualifiedName".
|
||
pub fn validateAndExtract(namespace_: ?[]const u8, qualified_name: []const u8, comptime context: enum { element, attribute }) !ValidatedName {
|
||
var namespace: ?[]const u8 = namespace_;
|
||
if (namespace) |ns| {
|
||
if (ns.len == 0) {
|
||
namespace = null;
|
||
}
|
||
}
|
||
|
||
var prefix: ?[]const u8 = null;
|
||
var local_name = qualified_name;
|
||
if (std.mem.indexOfScalar(u8, qualified_name, ':')) |colon| {
|
||
prefix = qualified_name[0..colon];
|
||
local_name = qualified_name[colon + 1 ..];
|
||
if (!isValidNamespacePrefix(prefix.?)) {
|
||
return error.InvalidCharacterError;
|
||
}
|
||
}
|
||
|
||
const local_valid = switch (context) {
|
||
.element => isValidElementLocalName(local_name),
|
||
.attribute => isValidAttributeLocalName(local_name),
|
||
};
|
||
if (!local_valid) {
|
||
return error.InvalidCharacterError;
|
||
}
|
||
|
||
if (prefix != null and namespace == null) {
|
||
return error.NamespaceError;
|
||
}
|
||
if (prefix) |p| {
|
||
if (std.mem.eql(u8, p, "xml") and (namespace == null or !std.mem.eql(u8, namespace.?, "http://www.w3.org/XML/1998/namespace"))) {
|
||
return error.NamespaceError;
|
||
}
|
||
}
|
||
const is_xmlns = std.mem.eql(u8, qualified_name, "xmlns") or (prefix != null and std.mem.eql(u8, prefix.?, "xmlns"));
|
||
const ns_is_xmlns = namespace != null and std.mem.eql(u8, namespace.?, "http://www.w3.org/2000/xmlns/");
|
||
if (is_xmlns != ns_is_xmlns) {
|
||
return error.NamespaceError;
|
||
}
|
||
|
||
return .{ .prefix = prefix, .local_name = local_name, .namespace = namespace };
|
||
}
|
||
|
||
// When a frame's URL is about:blank, or as soon as a frame is
|
||
// programmatically created, it has this default "blank" content
|
||
pub fn injectBlank(self: *Document, frame: *Frame) error{InjectBlankError}!void {
|
||
self._injectBlank(frame) catch |err| {
|
||
// we wrap _injectBlank like this so that injectBlank can only return an
|
||
// InjectBlankError. injectBlank is used in when nodes are inserted
|
||
// as since it inserts node itself, Zig can't infer the error set.
|
||
log.err(.browser, "inject blank", .{ .err = err });
|
||
return error.InjectBlankError;
|
||
};
|
||
}
|
||
|
||
fn _injectBlank(self: *Document, frame: *Frame) !void {
|
||
if (comptime lp.IS_DEBUG) {
|
||
// should only be called on an empty document
|
||
std.debug.assert(self.asNode()._first_child == null);
|
||
}
|
||
|
||
const html = try Frame.node_factory.createElementNS(frame, .html, "html", null);
|
||
const head = try Frame.node_factory.createElementNS(frame, .html, "head", null);
|
||
const body = try Frame.node_factory.createElementNS(frame, .html, "body", null);
|
||
try frame.appendNode(html, head, .{});
|
||
try frame.appendNode(html, body, .{});
|
||
try frame.appendNode(self.asNode(), html, .{});
|
||
}
|
||
|
||
const ReadyState = enum {
|
||
loading,
|
||
interactive,
|
||
complete,
|
||
};
|
||
|
||
pub const JsApi = struct {
|
||
pub const bridge = js.Bridge(Document);
|
||
|
||
pub const Meta = struct {
|
||
pub const name = "Document";
|
||
pub const prototype_chain = bridge.prototypeChain();
|
||
pub var class_id: bridge.ClassId = undefined;
|
||
};
|
||
|
||
pub const constructor = bridge.constructor(_constructor, .{});
|
||
fn _constructor(frame: *Frame) !*Document {
|
||
return frame._factory.node(Document{
|
||
._proto = undefined,
|
||
._type = .generic,
|
||
._url = "about:blank",
|
||
._charset = "UTF-8",
|
||
});
|
||
}
|
||
|
||
pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{});
|
||
pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{});
|
||
pub const ontouchstart = bridge.accessor(handlerAccessor(.ontouchstart).get, handlerAccessor(.ontouchstart).set, .{});
|
||
pub const ontouchend = bridge.accessor(handlerAccessor(.ontouchend).get, handlerAccessor(.ontouchend).set, .{});
|
||
pub const ontouchmove = bridge.accessor(handlerAccessor(.ontouchmove).get, handlerAccessor(.ontouchmove).set, .{});
|
||
pub const ontouchcancel = bridge.accessor(handlerAccessor(.ontouchcancel).get, handlerAccessor(.ontouchcancel).set, .{});
|
||
pub const URL = bridge.accessor(Document.getURL, null, .{});
|
||
pub const location = bridge.accessor(Document.getLocation, Document.setLocation, .{});
|
||
pub const documentURI = bridge.accessor(Document.getURL, null, .{});
|
||
pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{});
|
||
pub const scrollingElement = bridge.accessor(Document.getDocumentElement, null, .{});
|
||
pub const children = bridge.accessor(Document.getChildren, null, .{});
|
||
pub const readyState = bridge.accessor(Document.getReadyState, null, .{});
|
||
pub const implementation = bridge.accessor(Document.getImplementation, null, .{});
|
||
pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{});
|
||
pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{});
|
||
pub const fonts = bridge.accessor(Document.getFonts, null, .{});
|
||
pub const contentType = bridge.accessor(Document.getContentType, null, .{});
|
||
pub const domain = bridge.accessor(Document.getDomain, Document.setDomain, .{});
|
||
pub const cookie = bridge.accessor(Document.getCookie, Document.setCookie, .{});
|
||
pub const createElement = bridge.function(Document.createElement, .{});
|
||
pub const createElementNS = bridge.function(Document.createElementNS, .{});
|
||
pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{});
|
||
pub const createComment = bridge.function(Document.createComment, .{});
|
||
pub const createTextNode = bridge.function(Document.createTextNode, .{});
|
||
pub const createAttribute = bridge.function(Document.createAttribute, .{});
|
||
pub const createAttributeNS = bridge.function(Document.createAttributeNS, .{});
|
||
pub const createCDATASection = bridge.function(Document.createCDATASection, .{});
|
||
pub const createProcessingInstruction = bridge.function(Document.createProcessingInstruction, .{});
|
||
pub const createRange = bridge.function(Document.createRange, .{});
|
||
pub const createEvent = bridge.function(Document.createEvent, .{});
|
||
pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{});
|
||
pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{});
|
||
pub const evaluate = bridge.function(Document.evaluate, .{});
|
||
pub const createExpression = bridge.function(Document.createExpression, .{});
|
||
pub const createNSResolver = bridge.function(Document.createNSResolver, .{});
|
||
pub const getElementById = bridge.function(_getElementById, .{});
|
||
fn _getElementById(self: *Document, value_: ?js.Value, frame: *Frame) !?*Element {
|
||
const value = value_ orelse return null;
|
||
if (value.isNull()) {
|
||
return self.getElementById("null", frame);
|
||
}
|
||
if (value.isUndefined()) {
|
||
return self.getElementById("undefined", frame);
|
||
}
|
||
return self.getElementById(try value.toZig([]const u8), frame);
|
||
}
|
||
pub const querySelector = bridge.function(Document.querySelector, .{});
|
||
pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{});
|
||
pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{});
|
||
pub const getElementsByTagNameNS = bridge.function(Document.getElementsByTagNameNS, .{});
|
||
pub const getSelection = bridge.function(Document.getSelection, .{});
|
||
pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{});
|
||
pub const getElementsByName = bridge.function(Document.getElementsByName, .{});
|
||
pub const adoptNode = bridge.function(Document.adoptNode, .{ .ce_reactions = true });
|
||
pub const importNode = bridge.function(Document.importNode, .{ .ce_reactions = true });
|
||
pub const append = bridge.function(Document.append, .{ .ce_reactions = true });
|
||
pub const prepend = bridge.function(Document.prepend, .{ .ce_reactions = true });
|
||
pub const moveBefore = bridge.function(Document.moveBefore, .{ .ce_reactions = true });
|
||
pub const replaceChildren = bridge.function(Document.replaceChildren, .{ .ce_reactions = true });
|
||
pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{});
|
||
pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{});
|
||
pub const write = bridge.function(Document.write, .{ .ce_reactions = true });
|
||
pub const writeln = bridge.function(Document.writeln, .{ .ce_reactions = true });
|
||
pub const open = bridge.function(Document.open, .{ .ce_reactions = true });
|
||
pub const close = bridge.function(Document.close, .{ .ce_reactions = true });
|
||
pub const doctype = bridge.accessor(Document.getDocType, null, .{});
|
||
pub const firstElementChild = bridge.accessor(Document.getFirstElementChild, null, .{});
|
||
pub const lastElementChild = bridge.accessor(Document.getLastElementChild, null, .{});
|
||
pub const childElementCount = bridge.accessor(Document.getChildElementCount, null, .{});
|
||
pub const adoptedStyleSheets = bridge.accessor(Document.getAdoptedStyleSheets, Document.setAdoptedStyleSheets, .{});
|
||
pub const hidden = bridge.property(false, .{ .template = false, .readonly = true });
|
||
pub const visibilityState = bridge.property("visible", .{ .template = false, .readonly = true });
|
||
pub const defaultView = bridge.accessor(struct {
|
||
fn defaultView(self: *const Document) ?*@import("Window.zig") {
|
||
const frame = self._frame orelse return null;
|
||
return frame.window;
|
||
}
|
||
}.defaultView, null, .{});
|
||
pub const hasFocus = bridge.function(Document.hasFocus, .{});
|
||
|
||
pub const prerendering = bridge.property(false, .{ .template = false });
|
||
pub const characterSet = bridge.accessor(Document.getCharset, null, .{});
|
||
pub const charset = bridge.accessor(Document.getCharset, null, .{});
|
||
pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{});
|
||
pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
|
||
pub const lastModified = bridge.accessor(Document.getLastModified, null, .{});
|
||
pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
|
||
|
||
// Generates a getter/setter pair backed by the frame's attribute-listener
|
||
// map, like onclick above, for other document event handler properties.
|
||
fn handlerAccessor(comptime handler: @import("global_event_handlers.zig").Handler) type {
|
||
return struct {
|
||
pub fn get(self: *Document, frame: *Frame) ?js.Function.Global {
|
||
const owner = self._frame orelse frame;
|
||
return owner._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = handler });
|
||
}
|
||
|
||
pub fn set(self: *Document, setter: ?Window.FunctionSetter, frame: *Frame) !void {
|
||
const owner = self._frame orelse frame;
|
||
if (Window.getFunctionFromSetter(setter)) |cb| {
|
||
try owner._event_target_attr_listeners.put(owner.arena, .{ .target = self.asEventTarget(), .handler = handler }, cb);
|
||
} else {
|
||
_ = owner._event_target_attr_listeners.remove(.{ .target = self.asEventTarget(), .handler = handler });
|
||
}
|
||
}
|
||
};
|
||
}
|
||
};
|
||
|
||
const testing = @import("../../testing.zig");
|
||
test "WebApi: Document" {
|
||
try testing.htmlRunner("document", .{});
|
||
}
|
||
|
||
test "WebApi: Document.evaluate" {
|
||
try testing.htmlRunner("xpath/document_evaluate.html", .{});
|
||
}
|
||
|
||
test "Document: isRelaxableTo" {
|
||
// Pure opt-in (relax to self) is always allowed, including IP hosts.
|
||
try testing.expectEqual(true, isRelaxableTo("a.example.com", "a.example.com"));
|
||
try testing.expectEqual(true, isRelaxableTo("127.0.0.1", "127.0.0.1"));
|
||
|
||
// Relaxing to a registrable superdomain.
|
||
try testing.expectEqual(true, isRelaxableTo("a.example.com", "example.com"));
|
||
try testing.expectEqual(true, isRelaxableTo("a.b.example.com", "example.com"));
|
||
try testing.expectEqual(true, isRelaxableTo("a.b.example.com", "b.example.com"));
|
||
|
||
// Bare TLDs (single label) are never registrable. Multi-label public
|
||
// suffixes are rejected via the PSL — note the test build stubs the PSL
|
||
// to {gov.uk, api.gov.uk}, so those are the entries exercised here.
|
||
try testing.expectEqual(false, isRelaxableTo("a.example.com", "com"));
|
||
try testing.expectEqual(false, isRelaxableTo("foo.gov.uk", "gov.uk"));
|
||
try testing.expectEqual(false, isRelaxableTo("a.api.gov.uk", "api.gov.uk"));
|
||
// ...but a registrable domain sitting under that suffix is fine.
|
||
try testing.expectEqual(true, isRelaxableTo("a.dept.gov.uk", "dept.gov.uk"));
|
||
|
||
// Must be a label-boundary suffix, not a substring suffix.
|
||
try testing.expectEqual(false, isRelaxableTo("a.example.com", "ample.com"));
|
||
try testing.expectEqual(false, isRelaxableTo("notexample.com", "example.com"));
|
||
|
||
// Unrelated domain, and the empty string.
|
||
try testing.expectEqual(false, isRelaxableTo("a.example.com", "example.org"));
|
||
try testing.expectEqual(false, isRelaxableTo("a.example.com", ""));
|
||
}
|