Files
browser/src/browser/Page.zig
Karl Seguin 1b288c541a Merge pull request #1616 from lightpanda-io/URL_createObjectURL
Add URL.createObjectURL and URL.revokeObjectURL
2026-02-21 07:02:01 +08:00

3265 lines
125 KiB
Zig

// Copyright (C) 2023-2025 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 JS = @import("js/js.zig");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const log = @import("../log.zig");
const App = @import("../App.zig");
const String = @import("../string.zig").String;
const Mime = @import("Mime.zig");
const Factory = @import("Factory.zig");
const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig");
const Parser = @import("parser/Parser.zig");
const URL = @import("URL.zig");
const Blob = @import("webapi/Blob.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Http = App.Http;
const ArenaPool = App.ArenaPool;
const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const WebApiURL = @import("webapi/URL.zig");
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const GlobalEventHandlersLookup = global_event_handlers.Lookup;
const GlobalEventHandler = global_event_handlers.Handler;
var default_url = WebApiURL{ ._raw = "about:blank" };
pub var default_location: Location = Location{ ._url = &default_url };
pub const BUF_SIZE = 1024;
const Page = @This();
// This is the "id" of the frame. It can be re-used from page-to-page, e.g.
// when navigating.
id: u32,
_session: *Session,
_event_manager: EventManager,
_parse_mode: enum { document, fragment, document_write } = .document,
// See Attribute.List for what this is. TL;DR: proper DOM Attribute Nodes are
// fat yet rarely needed. We only create them on-demand, but still need proper
// identity (a given attribute should return the same *Attribute), so we do
// a look here. We don't store this in the Element or Attribute.List.Entry
// because that would require additional space per element / Attribute.List.Entry
// even thoug we'll create very few (if any) actual *Attributes.
_attribute_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute) = .empty,
// Same as _atlribute_lookup, but instead of individual attributes, this is for
// the return of elements.attributes.
_attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attribute.NamedNodeMap) = .empty,
// Lazily-created style, classList, and dataset objects. Only stored for elements
// that actually access these features via JavaScript, saving 24 bytes per element.
_element_styles: Element.StyleLookup = .empty,
_element_datasets: Element.DatasetLookup = .empty,
_element_class_lists: Element.ClassListLookup = .empty,
_element_rel_lists: Element.RelListLookup = .empty,
_element_shadow_roots: Element.ShadowRootLookup = .empty,
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_assigned_slots: Element.AssignedSlotLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
/// Lazily-created inline event listeners (or listeners provided as attributes).
/// Avoids bloating all elements with extra function fields for rare usage.
///
/// Use this when a listener provided like this:
///
/// ```js
/// img.onload = () => { ... };
/// ```
///
/// Its also used as cache for such cases after lazy evaluation:
///
/// ```html
/// <img onload="(() => { ... })()" />
/// ```
///
/// ```js
/// img.setAttribute("onload", "(() => { ... })()");
/// ```
_element_attr_listeners: GlobalEventHandlersLookup = .empty,
// Blob URL registry for URL.createObjectURL/revokeObjectURL
_blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// `load` events that'll be fired before window's `load` event.
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element) = .{},
_script_manager: ScriptManager,
// List of active MutationObservers
_mutation_observers: std.DoublyLinkedList = .{},
_mutation_delivery_scheduled: bool = false,
_mutation_delivery_depth: u32 = 0,
// List of active IntersectionObservers
_intersection_observers: std.ArrayList(*IntersectionObserver) = .{},
_intersection_check_scheduled: bool = false,
_intersection_delivery_scheduled: bool = false,
// Slots that need slotchange events to be fired
_slots_pending_slotchange: std.AutoHashMapUnmanaged(*Element.Html.Slot, void) = .{},
_slotchange_delivery_scheduled: bool = false,
/// List of active PerformanceObservers.
/// Contrary to MutationObserver and IntersectionObserver, these are regular tasks.
_performance_observers: std.ArrayList(*PerformanceObserver) = .{},
_performance_delivery_scheduled: bool = false,
// Lookup for customized built-in elements. Maps element pointer to definition.
_customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{},
_customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{},
_customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{},
// This is set when an element is being upgraded (constructor is called).
// The constructor can access this to get the element being upgraded.
_upgrading_element: ?*Node = null,
// List of custom elements that were created before their definition was registered
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},
// for heap allocations and managing WebAPI objects
_factory: Factory,
_load_state: LoadState = .waiting,
_parse_state: ParseState = .pre,
_notified_network_idle: IdleNotification = .init,
_notified_network_almost_idle: IdleNotification = .init,
// A navigation event that happens from a script gets scheduled to run on the
// next tick.
_queued_navigation: ?QueuedNavigation = null,
// The URL of the current page
url: [:0]const u8 = "about:blank",
// The base url specifies the base URL used to resolve the relative urls.
// It is set by a <base> tag.
// If null the url must be used.
base_url: ?[:0]const u8 = null,
// referer header cache.
referer_header: ?[:0]const u8 = null,
// Arbitrary buffer. Need to temporarily lowercase a value? Use this. No lifetime
// guarantee - it's valid until someone else uses it.
buf: [BUF_SIZE]u8 = undefined,
// access to the JavaScript engine
js: *JS.Context,
// An arena for the lifetime of the page.
arena: Allocator,
// An arena with a lifetime guaranteed to be for 1 invoking of a Zig function
// from JS. Best arena to use, when possible.
call_arena: Allocator,
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void) = if (IS_DEBUG) .empty else {},
window: *Window,
document: *Document,
// DOM version used to invalidate cached state of "live" collections
version: usize = 0,
_req_id: u32 = 0,
_navigated_options: ?NavigatedOpts = null,
pub fn init(self: *Page, id: u32, session: *Session) !void {
if (comptime IS_DEBUG) {
log.debug(.page, "page.init", .{});
}
const browser = session.browser;
const arena_pool = browser.arena_pool;
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
const call_arena = try arena_pool.acquire();
errdefer arena_pool.release(call_arena);
var factory = Factory.init(page_arena, self);
const document = (try factory.document(Node.Document.HTMLDocument{
._proto = undefined,
})).asDocument();
self.* = .{
.id = id,
.js = undefined,
.arena = page_arena,
.document = document,
.window = undefined,
.arena_pool = arena_pool,
.call_arena = call_arena,
._session = session,
._factory = factory,
._script_manager = undefined,
._event_manager = EventManager.init(page_arena, self),
};
self.window = try factory.eventTarget(Window{
._proto = undefined,
._document = self.document,
._location = &default_location,
._performance = Performance.init(),
._screen = try factory.eventTarget(Screen{
._proto = undefined,
._orientation = null,
}),
._visual_viewport = try factory.eventTarget(VisualViewport{
._proto = undefined,
}),
});
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, true);
errdefer self.js.deinit();
if (comptime builtin.is_test == false) {
// HTML test runner manually calls these as necessary
try self.js.scheduler.add(session.browser, struct {
fn runMessageLoop(ctx: *anyopaque) !?u32 {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
b.runMessageLoop();
return 250;
}
}.runMessageLoop, 250, .{ .name = "page.messageLoop" });
}
}
pub fn deinit(self: *Page) void {
if (comptime IS_DEBUG) {
log.debug(.page, "page.deinit", .{ .url = self.url });
// Uncomment if you want slab statistics to print.
// const stats = self._factory._slab.getStats(self.arena) catch unreachable;
// var buffer: [256]u8 = undefined;
// var stream = std.fs.File.stderr().writer(&buffer).interface;
// stats.print(&stream) catch unreachable;
}
const session = self._session;
session.browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
session.browser.http_client.abort();
self._script_manager.deinit();
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
}
self.arena_pool.release(self.call_arena);
self.arena_pool.release(self.arena);
}
pub fn base(self: *const Page) [:0]const u8 {
return self.base_url orelse self.url;
}
pub fn getTitle(self: *Page) !?[]const u8 {
if (self.window._document.is(Document.HTMLDocument)) |html_doc| {
return try html_doc.getTitle(self);
}
return null;
}
pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 {
return try URL.getOrigin(allocator, self.url);
}
// Add comon headers for a request:
// * cookies
// * referer
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void {
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
// Build the referer
const referer = blk: {
if (self.referer_header == null) {
// build the cache
if (std.mem.startsWith(u8, self.url, "http")) {
self.referer_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", self.url }, 0);
} else {
self.referer_header = "";
}
}
break :blk self.referer_header.?;
};
// If the referer is empty, ignore the header.
if (referer.len > 0) {
try headers.add(referer);
}
}
const GetArenaOpts = struct {
debug: []const u8,
};
pub fn getArena(self: *Page, comptime opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing) {
std.debug.assert(gop.value_ptr.count == 0);
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
}
pub fn releaseArena(self: *Page, allocator: Allocator) void {
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
}
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false;
return std.mem.startsWith(u8, url, current_origin);
}
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void {
lp.assert(self._load_state == .waiting, "page.renavigate", .{});
const session = self._session;
self._load_state = .parsing;
const req_id = self._session.browser.http_client.nextReqId();
log.info(.page, "navigate", .{
.url = request_url,
.method = opts.method,
.reason = opts.reason,
.body = opts.body != null,
.req_id = req_id,
});
// if the url is about:blank, we load an empty HTML document in the
// page and dispatch the events.
if (std.mem.eql(u8, "about:blank", request_url)) {
// Assume we parsed the document.
// It's important to force a reset during the following navigation.
self._parse_state = .complete;
// We do not processHTMLDoc here as we know we don't have any scripts
// This assumption may be false when CDP Page.addScriptToEvaluateOnNewDocument is implemented
self.documentIsComplete();
session.notification.dispatch(.page_navigate, &.{
.page_id = self.id,
.req_id = req_id,
.opts = opts,
.url = request_url,
.timestamp = timestamp(.monotonic),
});
// Record telemetry for navigation
session.browser.app.telemetry.record(.{
.navigate = .{
.tls = false, // about:blank is not TLS
.proxy = session.browser.app.config.httpProxy() != null,
},
});
session.notification.dispatch(.page_navigated, &.{
.page_id = self.id,
.req_id = req_id,
.opts = .{
.cdp_id = opts.cdp_id,
.reason = opts.reason,
.method = opts.method,
},
.url = request_url,
.timestamp = timestamp(.monotonic),
});
// force next request id manually b/c we won't create a real req.
_ = session.browser.http_client.incrReqId();
return;
}
var http_client = session.browser.http_client;
self.url = try self.arena.dupeZ(u8, request_url);
self._req_id = req_id;
self._navigated_options = .{
.cdp_id = opts.cdp_id,
.reason = opts.reason,
.method = opts.method,
};
var headers = try http_client.newHeaders();
if (opts.header) |hdr| {
try headers.add(hdr);
}
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, self.url, &headers);
// We dispatch page_navigate event before sending the request.
// It ensures the event page_navigated is not dispatched before this one.
session.notification.dispatch(.page_navigate, &.{
.page_id = self.id,
.req_id = req_id,
.opts = opts,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
// Record telemetry for navigation
session.browser.app.telemetry.record(.{ .navigate = .{
.tls = std.ascii.startsWithIgnoreCase(self.url, "https://"),
.proxy = session.browser.app.config.httpProxy() != null,
} });
session.navigation._current_navigation_kind = opts.kind;
http_client.request(.{
.ctx = self,
.url = self.url,
.page_id = self.id,
.method = opts.method,
.headers = headers,
.body = opts.body,
.cookie_jar = &session.cookie_jar,
.resource_type = .document,
.notification = self._session.notification,
.header_callback = pageHeaderDoneCallback,
.data_callback = pageDataCallback,
.done_callback = pageDoneCallback,
.error_callback = pageErrorCallback,
}) catch |err| {
log.err(.page, "navigate request", .{ .url = self.url, .err = err });
return err;
};
}
// We cannot navigate immediately as navigating will delete the DOM tree,
// which holds this event's node.
// As such we schedule the function to be called as soon as possible.
// The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime.
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
if (self.canScheduleNavigation(priority) == false) {
return;
}
const session = self._session;
const resolved_url = try URL.resolve(
session.transfer_arena,
self.base(),
request_url,
.{ .always_dupe = true },
);
if (!opts.force and URL.eqlDocument(self.url, resolved_url)) {
self.url = try self.arena.dupeZ(u8, resolved_url);
self.window._location = try Location.init(self.url, self);
self.document._location = self.window._location;
return session.navigation.updateEntries(self.url, opts.kind, self, true);
}
log.info(.browser, "schedule navigation", .{
.url = resolved_url,
.reason = opts.reason,
.target = resolved_url,
});
self._session.browser.http_client.abort();
self._queued_navigation = .{
.opts = opts,
.url = resolved_url,
.priority = priority,
};
}
// A script can have multiple competing navigation events, say it starts off
// by doing top.location = 'x' and then does a form submission.
// You might think that we just stop at the first one, but that doesn't seem
// to be what browsers do, and it isn't particularly well supported by v8 (i.e.
// halting execution mid-script).
// From what I can tell, there are 3 "levels" of priority, in order:
// 1 - form submission
// 2 - JavaScript apis (e.g. top.location)
// 3 - anchor clicks
// Within, each category, it's last-one-wins.
fn canScheduleNavigation(self: *Page, priority: NavigationPriority) bool {
const existing = self._queued_navigation orelse return true;
if (existing.priority == priority) {
// same reason, than this latest one wins
return true;
}
return switch (existing.priority) {
.anchor => true, // everything is higher priority than an anchor
.form => false, // nothing is higher priority than a form
.script => priority == .form, // a form is higher priority than a script
};
}
pub fn documentIsLoaded(self: *Page) void {
if (self._load_state != .parsing) {
// Ideally, documentIsLoaded would only be called once, but if a
// script is dynamically added from an async script after
// documentIsLoaded is already called, then ScriptManager will call
// it again.
return;
}
self._load_state = .load;
self.document._ready_state = .interactive;
self._documentIsLoaded() catch |err| {
log.err(.page, "document is loaded", .{ .err = err });
};
}
pub fn _documentIsLoaded(self: *Page) !void {
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(
self.document.asEventTarget(),
event,
);
}
pub fn documentIsComplete(self: *Page) void {
if (self._load_state == .complete) {
// Ideally, documentIsComplete would only be called once, but with
// dynamic scripts, it can be hard to keep track of that. An async
// script could be evaluated AFTER Loaded and Complete and load its
// own non non-async script - which, upon completion, needs to check
// whether Laoded/Complete have already been called, which is what
// this guard is.
return;
}
// documentIsComplete could be called directly, without first calling
// documentIsLoaded, if there were _only_ async scripts
if (self._load_state == .parsing) {
self.documentIsLoaded();
}
self._load_state = .complete;
self._documentIsComplete() catch |err| {
log.err(.page, "document is complete", .{ .err = err });
};
if (IS_DEBUG) {
std.debug.assert(self._navigated_options != null);
}
self._session.notification.dispatch(.page_navigated, &.{
.page_id = self.id,
.req_id = self._req_id,
.opts = self._navigated_options.?,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
fn _documentIsComplete(self: *Page) !void {
self.document._ready_state = .complete;
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
// Dispatch `_to_load` events before window.load.
for (self._to_load.items) |element| {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(element.asEventTarget(), event);
}
// `_to_load` can be cleaned here.
self._to_load.clearAndFree(self.arena);
// Dispatch window.load event.
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false);
// This event is weird, it's dispatched directly on the window, but
// with the document as the target.
event._target = self.document.asEventTarget();
try self._event_manager.dispatchWithFunction(
self.window.asEventTarget(),
event,
ls.toLocal(self.window._on_load),
.{ .inject_target = false, .context = "page load" },
);
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
defer if (!pageshow_event._v8_handoff) pageshow_event.deinit(false);
try self._event_manager.dispatchWithFunction(
self.window.asEventTarget(),
pageshow_event,
ls.toLocal(self.window._on_pageshow),
.{ .context = "page show" },
);
}
fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
// would be different than self.url in the case of a redirect
const header = &transfer.response_header.?;
self.url = try self.arena.dupeZ(u8, std.mem.span(header.url));
self.window._location = try Location.init(self.url, self);
self.document._location = self.window._location;
if (comptime IS_DEBUG) {
log.debug(.page, "navigate header", .{
.url = self.url,
.status = header.status,
.content_type = header.contentType(),
});
}
return true;
}
fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data
// to sniff the content type
const mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| {
break :blk try Mime.parse(ct);
}
break :blk Mime.sniff(data);
} orelse .unknown;
if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len });
}
switch (mime.content_type) {
.text_html => self._parse_state = .{ .html = .{} },
.application_json, .text_javascript, .text_css, .text_plain => {
var arr: std.ArrayList(u8) = .empty;
try arr.appendSlice(self.arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
self._parse_state = .{ .text = arr };
},
.image_jpeg, .image_gif, .image_png, .image_webp => {
self._parse_state = .{ .image = .empty };
},
else => self._parse_state = .{ .raw = .empty },
}
}
switch (self._parse_state) {
.html => |*buf| try buf.appendSlice(self.arena, data),
.text => |*buf| {
// we have to escape the data...
var v = data;
while (v.len > 0) {
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '<', '>' }) orelse {
return buf.appendSlice(self.arena, v);
};
try buf.appendSlice(self.arena, v[0..index]);
switch (v[index]) {
'<' => try buf.appendSlice(self.arena, "&lt;"),
'>' => try buf.appendSlice(self.arena, "&gt;"),
else => unreachable,
}
v = v[index + 1 ..];
}
},
.raw, .image => |*buf| try buf.appendSlice(self.arena, data),
.pre => unreachable,
.complete => unreachable,
.err => unreachable,
.raw_done => unreachable,
}
}
fn pageDoneCallback(ctx: *anyopaque) !void {
if (comptime IS_DEBUG) {
log.debug(.page, "navigate done", .{});
}
var self: *Page = @ptrCast(@alignCast(ctx));
self.clearTransferArena();
//We need to handle different navigation types differently.
try self._session.navigation.commitNavigation(self);
defer if (comptime IS_DEBUG) {
log.debug(.page, "page.load.complete", .{ .url = self.url });
};
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
defer self.releaseArena(parse_arena);
var parser = Parser.init(parse_arena, self.document.asNode(), self);
switch (self._parse_state) {
.html => |buf| {
parser.parse(buf.items);
self._script_manager.staticScriptsDone();
if (self._script_manager.isDone()) {
// No scripts, or just inline scripts that were already processed
// we need to trigger this ourselves
self.documentIsComplete();
}
self._parse_state = .complete;
},
.text => |*buf| {
try buf.appendSlice(self.arena, "</pre></body></html>");
parser.parse(buf.items);
self.documentIsComplete();
},
.image => |buf| {
self._parse_state = .{ .raw_done = buf.items };
// Use empty an HTML containing the image.
const html = try std.mem.concat(parse_arena, u8, &.{
"<html><head><meta charset=\"utf-8\"></head><body><img src=\"",
self.url,
"\"></body></htm>",
});
parser.parse(html);
self.documentIsComplete();
},
.raw => |buf| {
self._parse_state = .{ .raw_done = buf.items };
// Use empty an empty HTML document.
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></htm>");
self.documentIsComplete();
},
.pre => {
// Received a response without a body like: https://httpbin.io/status/200
// We assume we have received an OK status (checked in Client.headerCallback)
// so we load a blank document to navigate away from any prior page.
self._parse_state = .{ .complete = {} };
// Use empty an empty HTML document.
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></htm>");
self.documentIsComplete();
},
.err => |err| {
// Generate a pseudo HTML page indicating the failure.
const html = try std.mem.concat(parse_arena, u8, &.{
"<html><head><meta charset=\"utf-8\"></head><body><h1>Navigation failed</h1><p>Reason: ",
@errorName(err),
"</p></body></htm>",
});
parser.parse(html);
self.documentIsComplete();
},
else => unreachable,
}
}
fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
log.err(.page, "navigate failed", .{ .err = err });
var self: *Page = @ptrCast(@alignCast(ctx));
self._parse_state = .{ .err = err };
// In case of error, we want to complete the page with a custom HTML
// containing the error.
pageDoneCallback(ctx) catch |e| {
log.err(.browser, "pageErrorCallback", .{ .err = e });
return;
};
}
// The transfer arena is useful and interesting, but has a weird lifetime.
// When we're transferring from one page to another (via delayed navigation)
// we need things in memory: like the URL that we're navigating to and
// optionally the body to POST. That cannot exist in the page.arena, because
// the page that we have is going to be destroyed and a new page is going
// to be created. If we used the page.arena, we'd wouldn't be able to reset
// it between navigation.
// So the transfer arena is meant to exist between a navigation event. It's
// freed when the main html navigation is complete, either in pageDoneCallback
// or pageErrorCallback. It needs to exist for this long because, if we set
// a body, CURLOPT_POSTFIELDS does not copy the body (it optionally can, but
// why would we want to) and requires the body to live until the transfer
// is complete.
fn clearTransferArena(self: *Page) void {
self.arena_pool.reset(self._session.transfer_arena, 4 * 1024);
}
pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult {
return self._wait(wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => {
// There may be errors from the http/client or ScriptManager
// that we should not treat as an error like this. Will need
// to run this through more real-world sites and see if we need
// to expand the switch (err) to have more customized logs for
// specific messages.
log.err(.browser, "page wait", .{ .err = err });
},
}
return .done;
};
}
fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
const browser = self._session.browser;
var http_client = browser.http_client;
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
// for debugging
// defer self.printWaitAnalysis();
while (true) {
switch (self._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (self._queued_navigation != null) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (self._notified_network_almost_idle.check(total_network_activity <= 2)) {
self.notifyNetworkAlmostIdle();
}
if (self._notified_network_idle.check(total_network_activity == 0)) {
self.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
const ms = ms_to_next_task orelse blk: {
if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) {
return .done;
}
// Look, we want to exit ASAP, but we don't want
// to exit so fast that we've run none of the
// background jobs.
break :blk 50;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
return .done;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we
// minimize how long we'll poll for network I/O.
const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200));
if (try http_client.tick(ms_to_wait) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
self._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
}
fn printWaitAnalysis(self: *Page) void {
std.debug.print("load_state: {s}\n", .{@tagName(self._load_state)});
std.debug.print("parse_state: {s}\n", .{@tagName(std.meta.activeTag(self._parse_state))});
{
std.debug.print("\nactive requests: {d}\n", .{self._session.browser.http_client.active});
var n_ = self._session.browser.http_client.handles.in_use.first;
while (n_) |n| {
const handle: *Http.Client.Handle = @fieldParentPtr("node", n);
const transfer = Http.Transfer.fromEasy(handle.conn.easy) catch |err| {
std.debug.print(" - failed to load transfer: {any}\n", .{err});
break;
};
std.debug.print(" - {f}\n", .{transfer});
n_ = n.next;
}
}
{
std.debug.print("\nqueued requests: {d}\n", .{self._session.browser.http_client.queue.len()});
var n_ = self._session.browser.http_client.queue.first;
while (n_) |n| {
const transfer: *Http.Transfer = @fieldParentPtr("_node", n);
std.debug.print(" - {f}\n", .{transfer});
n_ = n.next;
}
}
{
std.debug.print("\ndeferreds: {d}\n", .{self._script_manager.defer_scripts.len()});
var n_ = self._script_manager.defer_scripts.first;
while (n_) |n| {
const script: *ScriptManager.Script = @fieldParentPtr("node", n);
std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete });
n_ = n.next;
}
}
{
std.debug.print("\nasyncs: {d}\n", .{self._script_manager.async_scripts.len()});
}
{
std.debug.print("\nasyncs ready: {d}\n", .{self._script_manager.ready_scripts.len()});
var n_ = self._script_manager.ready_scripts.first;
while (n_) |n| {
const script: *ScriptManager.Script = @fieldParentPtr("node", n);
std.debug.print(" - {s} complete: {any}\n", .{ script.url, script.complete });
n_ = n.next;
}
}
const now = milliTimestamp(.monotonic);
{
std.debug.print("\nhigh_priority schedule: {d}\n", .{self.js.scheduler.high_priority.count()});
var it = self.js.scheduler.high_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
}
{
std.debug.print("\nlow_priority schedule: {d}\n", .{self.js.scheduler.low_priority.count()});
var it = self.js.scheduler.low_priority.iterator();
while (it.next()) |task| {
std.debug.print(" - {s} schedule: {d}ms\n", .{ task.name, task.run_at - now });
}
}
}
pub fn isGoingAway(self: *const Page) bool {
return self._queued_navigation != null;
}
pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Element.Html.Script) !void {
if (self.isGoingAway()) {
// if we're planning on navigating to another page, don't run this script
return;
}
self._script_manager.addFromElement(from_parser, script, "parsing") catch |err| {
log.err(.page, "page.scriptAddedCallback", .{
.err = err,
.src = script.asElement().getAttributeSafe(comptime .wrap("src")),
});
};
}
pub fn domChanged(self: *Page) void {
self.version += 1;
if (self._intersection_check_scheduled) {
return;
}
self._intersection_check_scheduled = true;
self.js.queueIntersectionChecks() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err });
};
}
const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) };
fn getElementIdMap(page: *Page, node: *Node) ElementIdMaps {
// Walk up the tree checking for ShadowRoot and tracking the root
var current = node;
while (true) {
if (current.is(ShadowRoot)) |shadow_root| {
return .{
.lookup = &shadow_root._elements_by_id,
.removed_ids = &shadow_root._removed_ids,
};
}
const parent = current._parent orelse {
if (current._type == .document) {
return .{
.lookup = &current._type.document._elements_by_id,
.removed_ids = &current._type.document._removed_ids,
};
}
// Detached nodes should not have IDs registered
if (IS_DEBUG) {
std.debug.assert(false);
}
return .{
.lookup = &page.document._elements_by_id,
.removed_ids = &page.document._removed_ids,
};
};
current = parent;
}
}
pub fn addElementId(self: *Page, parent: *Node, element: *Element, id: []const u8) !void {
var id_maps = self.getElementIdMap(parent);
const gop = try id_maps.lookup.getOrPut(self.arena, id);
if (!gop.found_existing) {
gop.value_ptr.* = element;
return;
}
const existing = gop.value_ptr.*.asNode();
switch (element.asNode().compareDocumentPosition(existing)) {
0x04 => gop.value_ptr.* = element,
else => {},
}
}
pub fn removeElementId(self: *Page, element: *Element, id: []const u8) void {
const node = element.asNode();
self.removeElementIdWithMaps(self.getElementIdMap(node), id);
}
pub fn removeElementIdWithMaps(self: *Page, id_maps: ElementIdMaps, id: []const u8) void {
if (id_maps.lookup.remove(id)) {
id_maps.removed_ids.put(self.arena, self.dupeString(id) catch return, {}) catch {};
}
}
pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Element {
if (node.isConnected() or node.isInShadowTree()) {
const lookup = self.getElementIdMap(node).lookup;
return lookup.get(id);
}
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
const element_id = el.getAttributeSafe(comptime .wrap("id")) orelse continue;
if (std.mem.eql(u8, element_id, id)) {
return el;
}
}
return null;
}
/// Sets an inline event listener (`onload`, `onclick`, `onwheel` etc.);
/// overrides the listener if there's already one.
pub fn setAttrListener(
self: *Page,
element: *Element,
listener_type: GlobalEventHandler,
listener_callback: JS.Function.Global,
) !void {
if (comptime IS_DEBUG) {
log.debug(.event, "Page.setAttrListener", .{
.element = element,
.listener_type = listener_type,
});
}
const gop = try self._element_attr_listeners.getOrPut(self.arena, .{
.target = element.asEventTarget(),
.handler = listener_type,
});
gop.value_ptr.* = listener_callback;
}
/// Returns the inline event listener by an element and listener type.
pub fn getAttrListener(
self: *const Page,
element: *Element,
listener_type: GlobalEventHandler,
) ?JS.Function.Global {
return self._element_attr_listeners.get(.{
.target = element.asEventTarget(),
.handler = listener_type,
});
}
pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void {
return self._performance_observers.append(self.arena, observer);
}
pub fn unregisterPerformanceObserver(self: *Page, observer: *PerformanceObserver) void {
for (self._performance_observers.items, 0..) |perf_observer, i| {
if (perf_observer == observer) {
_ = self._performance_observers.swapRemove(i);
return;
}
}
}
/// Updates performance observers with the new entry.
/// This doesn't emit callbacks but rather fills the queues of observers.
pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void {
for (self._performance_observers.items) |observer| {
if (observer.interested(entry)) {
observer._entries.append(self.arena, entry) catch |err| {
log.err(.page, "notifyPerformanceObservers", .{ .err = err });
};
}
}
try self.schedulePerformanceObserverDelivery();
}
/// Schedules async delivery of performance observer records.
pub fn schedulePerformanceObserverDelivery(self: *Page) !void {
// Already scheduled.
if (self._performance_delivery_scheduled) {
return;
}
self._performance_delivery_scheduled = true;
return self.js.scheduler.add(
self,
struct {
fn run(_page: *anyopaque) anyerror!?u32 {
const page: *Page = @ptrCast(@alignCast(_page));
page._performance_delivery_scheduled = false;
// Dispatch performance observer events.
for (page._performance_observers.items) |observer| {
if (observer.hasRecords()) {
try observer.dispatch(page);
}
}
return null;
}
}.run,
0,
.{ .low_priority = true },
);
}
pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void {
self._mutation_observers.append(&observer.node);
}
pub fn unregisterMutationObserver(self: *Page, observer: *MutationObserver) void {
self._mutation_observers.remove(&observer.node);
}
pub fn registerIntersectionObserver(self: *Page, observer: *IntersectionObserver) !void {
try self._intersection_observers.append(self.arena, observer);
}
pub fn unregisterIntersectionObserver(self: *Page, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) {
_ = self._intersection_observers.swapRemove(i);
return;
}
}
}
pub fn checkIntersections(self: *Page) !void {
for (self._intersection_observers.items) |observer| {
try observer.checkIntersections(self);
}
}
pub fn scheduleMutationDelivery(self: *Page) !void {
if (self._mutation_delivery_scheduled) {
return;
}
self._mutation_delivery_scheduled = true;
try self.js.queueMutationDelivery();
}
pub fn scheduleIntersectionDelivery(self: *Page) !void {
if (self._intersection_delivery_scheduled) {
return;
}
self._intersection_delivery_scheduled = true;
try self.js.queueIntersectionDelivery();
}
pub fn scheduleSlotchangeDelivery(self: *Page) !void {
if (self._slotchange_delivery_scheduled) {
return;
}
self._slotchange_delivery_scheduled = true;
try self.js.queueSlotchangeDelivery();
}
pub fn performScheduledIntersectionChecks(self: *Page) void {
if (!self._intersection_check_scheduled) {
return;
}
self._intersection_check_scheduled = false;
self.checkIntersections() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err });
};
}
pub fn deliverIntersections(self: *Page) void {
if (!self._intersection_delivery_scheduled) {
return;
}
self._intersection_delivery_scheduled = false;
// Iterate backwards to handle observers that disconnect during their callback
var i = self._intersection_observers.items.len;
while (i > 0) {
i -= 1;
const observer = self._intersection_observers.items[i];
observer.deliverEntries(self) catch |err| {
log.err(.page, "page.deliverIntersections", .{ .err = err });
};
}
}
pub fn deliverMutations(self: *Page) void {
if (!self._mutation_delivery_scheduled) {
return;
}
self._mutation_delivery_scheduled = false;
self._mutation_delivery_depth += 1;
defer if (!self._mutation_delivery_scheduled) {
// reset the depth once nothing is left to be scheduled
self._mutation_delivery_depth = 0;
};
if (self._mutation_delivery_depth > 100) {
log.err(.page, "page.MutationLimit", .{});
self._mutation_delivery_depth = 0;
return;
}
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.deliverRecords(self) catch |err| {
log.err(.page, "page.deliverMutations", .{ .err = err });
};
}
}
pub fn deliverSlotchangeEvents(self: *Page) void {
if (!self._slotchange_delivery_scheduled) {
return;
}
self._slotchange_delivery_scheduled = false;
// we need to collect the pending slots, and then clear it and THEN exeute
// the slot change. We do this in case the slotchange event itself schedules
// more slot changes (which should only be executed on the next microtask)
const pending = self._slots_pending_slotchange.count();
var i: usize = 0;
var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| {
log.err(.page, "deliverSlotchange.append", .{ .err = err });
return;
};
var it = self._slots_pending_slotchange.keyIterator();
while (it.next()) |slot| {
slots[i] = slot.*;
i += 1;
}
self._slots_pending_slotchange.clearRetainingCapacity();
for (slots) |slot| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| {
log.err(.page, "deliverSlotchange.init", .{ .err = err });
continue;
};
defer if (!event._v8_handoff) event.deinit(false);
const target = slot.asNode().asEventTarget();
_ = target.dispatchEvent(event, self) catch |err| {
log.err(.page, "deliverSlotchange.dispatch", .{ .err = err });
};
}
}
fn notifyNetworkIdle(self: *Page) void {
lp.assert(self._notified_network_idle == .done, "Page.notifyNetworkIdle", .{});
self._session.notification.dispatch(.page_network_idle, &.{
.page_id = self.id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
}
fn notifyNetworkAlmostIdle(self: *Page) void {
lp.assert(self._notified_network_almost_idle == .done, "Page.notifyNetworkAlmostIdle", .{});
self._session.notification.dispatch(.page_network_almost_idle, &.{
.page_id = self.id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
}
// called from the parser
pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void {
const node = switch (child) {
.node => |n| n,
.text => |txt| blk: {
// If we're appending this adjacently to a text node, we should merge
if (parent.lastChild()) |sibling| {
if (sibling.is(CData.Text)) |tn| {
const cdata = tn._proto;
const existing = cdata.getData();
// @metric
// Inefficient, but we don't expect this to happen often.
cdata._data = try std.mem.concat(self.arena, u8, &.{ existing, txt });
return;
}
}
break :blk try self.createTextNode(txt);
},
};
lp.assert(node._parent == null, "Page.appendNew", .{});
try self._insertNodeRelative(true, parent, node, .append, .{
// this opts has no meaning since we're passing `true` as the first
// parameter, which indicates this comes from the parser, and has its
// own special processing. Still, set it to be clear.
.child_already_connected = false,
});
}
// called from the parser when the node and all its children have been added
pub fn nodeComplete(self: *Page, node: *Node) !void {
Node.Build.call(node, "complete", .{ node, self }) catch |err| {
log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err });
return err;
};
return self.nodeIsReady(true, node);
}
// Sets the owner document for a node. Only stores entries for nodes whose owner
// is NOT page.document to minimize memory overhead.
pub fn setNodeOwnerDocument(self: *Page, node: *Node, owner: *Document) !void {
if (owner == self.document) {
// No need to store if it's the main document - remove if present
_ = self._node_owner_documents.remove(node);
} else {
try self._node_owner_documents.put(self.arena, node, owner);
}
}
// Recursively sets the owner document for a node and all its descendants
pub fn adoptNodeTree(self: *Page, node: *Node, new_owner: *Document) !void {
try self.setNodeOwnerDocument(node, new_owner);
var it = node.childrenIterator();
while (it.next()) |child| {
try self.adoptNodeTree(child, new_owner);
}
}
pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const u8, attribute_iterator: anytype) !*Node {
switch (namespace) {
.html => {
switch (name.len) {
1 => switch (name[0]) {
'p' => return self.createHtmlElementT(
Element.Html.Paragraph,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
'a' => return self.createHtmlElementT(
Element.Html.Anchor,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
'b' => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "b", .{}) catch unreachable, ._tag = .b },
),
'i' => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "i", .{}) catch unreachable, ._tag = .i },
),
'q' => return self.createHtmlElementT(
Element.Html.Quote,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "q", .{}) catch unreachable, ._tag = .quote },
),
's' => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "s", .{}) catch unreachable, ._tag = .s },
),
else => {},
},
2 => switch (@as(u16, @bitCast(name[0..2].*))) {
asUint("br") => return self.createHtmlElementT(
Element.Html.BR,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("ol") => return self.createHtmlElementT(
Element.Html.OL,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("ul") => return self.createHtmlElementT(
Element.Html.UL,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("li") => return self.createHtmlElementT(
Element.Html.LI,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("h1") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h1", .{}) catch unreachable, ._tag = .h1 },
),
asUint("h2") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h2", .{}) catch unreachable, ._tag = .h2 },
),
asUint("h3") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h3", .{}) catch unreachable, ._tag = .h3 },
),
asUint("h4") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h4", .{}) catch unreachable, ._tag = .h4 },
),
asUint("h5") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h5", .{}) catch unreachable, ._tag = .h5 },
),
asUint("h6") => return self.createHtmlElementT(
Element.Html.Heading,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "h6", .{}) catch unreachable, ._tag = .h6 },
),
asUint("hr") => return self.createHtmlElementT(
Element.Html.HR,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("em") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "em", .{}) catch unreachable, ._tag = .em },
),
asUint("dd") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "dd", .{}) catch unreachable, ._tag = .dd },
),
asUint("dl") => return self.createHtmlElementT(
Element.Html.DList,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("dt") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "dt", .{}) catch unreachable, ._tag = .dt },
),
asUint("td") => return self.createHtmlElementT(
Element.Html.TableCell,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "td", .{}) catch unreachable, ._tag = .td },
),
asUint("th") => return self.createHtmlElementT(
Element.Html.TableCell,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "th", .{}) catch unreachable, ._tag = .th },
),
asUint("tr") => return self.createHtmlElementT(
Element.Html.TableRow,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
else => {},
},
3 => switch (@as(u24, @bitCast(name[0..3].*))) {
asUint("div") => return self.createHtmlElementT(
Element.Html.Div,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("img") => return self.createHtmlElementT(
Element.Html.Image,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("nav") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "nav", .{}) catch unreachable, ._tag = .nav },
),
asUint("del") => return self.createHtmlElementT(
Element.Html.Mod,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "del", .{}) catch unreachable, ._tag = .del },
),
asUint("ins") => return self.createHtmlElementT(
Element.Html.Mod,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "ins", .{}) catch unreachable, ._tag = .ins },
),
asUint("col") => return self.createHtmlElementT(
Element.Html.TableCol,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "col", .{}) catch unreachable, ._tag = .col },
),
asUint("dir") => return self.createHtmlElementT(
Element.Html.Directory,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("map") => return self.createHtmlElementT(
Element.Html.Map,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("pre") => return self.createHtmlElementT(
Element.Html.Pre,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("sub") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "sub", .{}) catch unreachable, ._tag = .sub },
),
asUint("sup") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "sup", .{}) catch unreachable, ._tag = .sup },
),
asUint("dfn") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "dfn", .{}) catch unreachable, ._tag = .dfn },
),
else => {},
},
4 => switch (@as(u32, @bitCast(name[0..4].*))) {
asUint("span") => return self.createHtmlElementT(
Element.Html.Span,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("meta") => return self.createHtmlElementT(
Element.Html.Meta,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("link") => return self.createHtmlElementT(
Element.Html.Link,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("slot") => return self.createHtmlElementT(
Element.Html.Slot,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("html") => return self.createHtmlElementT(
Element.Html.Html,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("head") => return self.createHtmlElementT(
Element.Html.Head,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("body") => return self.createHtmlElementT(
Element.Html.Body,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("form") => return self.createHtmlElementT(
Element.Html.Form,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("main") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "main", .{}) catch unreachable, ._tag = .main },
),
asUint("data") => return self.createHtmlElementT(
Element.Html.Data,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("base") => {
const n = try self.createHtmlElementT(
Element.Html.Base,
namespace,
attribute_iterator,
.{ ._proto = undefined },
);
// If page's base url is not already set, fill it with the base
// tag.
if (self.base_url == null) {
if (n.as(Element).getAttributeSafe(comptime .wrap("href"))) |href| {
self.base_url = try URL.resolve(self.arena, self.url, href, .{});
}
}
return n;
},
asUint("menu") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "menu", .{}) catch unreachable, ._tag = .menu },
),
asUint("area") => return self.createHtmlElementT(
Element.Html.Area,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("font") => return self.createHtmlElementT(
Element.Html.Font,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("code") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "code", .{}) catch unreachable, ._tag = .code },
),
asUint("time") => return self.createHtmlElementT(
Element.Html.Time,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
else => {},
},
5 => switch (@as(u40, @bitCast(name[0..5].*))) {
asUint("input") => return self.createHtmlElementT(
Element.Html.Input,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("style") => return self.createHtmlElementT(
Element.Html.Style,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("title") => return self.createHtmlElementT(
Element.Html.Title,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("embed") => return self.createHtmlElementT(
Element.Html.Embed,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("audio") => return self.createHtmlMediaElementT(
Element.Html.Media.Audio,
namespace,
attribute_iterator,
),
asUint("video") => return self.createHtmlMediaElementT(
Element.Html.Media.Video,
namespace,
attribute_iterator,
),
asUint("aside") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "aside", .{}) catch unreachable, ._tag = .aside },
),
asUint("label") => return self.createHtmlElementT(
Element.Html.Label,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("meter") => return self.createHtmlElementT(
Element.Html.Meter,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("param") => return self.createHtmlElementT(
Element.Html.Param,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("table") => return self.createHtmlElementT(
Element.Html.Table,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("thead") => return self.createHtmlElementT(
Element.Html.TableSection,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "thead", .{}) catch unreachable, ._tag = .thead },
),
asUint("tbody") => return self.createHtmlElementT(
Element.Html.TableSection,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "tbody", .{}) catch unreachable, ._tag = .tbody },
),
asUint("tfoot") => return self.createHtmlElementT(
Element.Html.TableSection,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "tfoot", .{}) catch unreachable, ._tag = .tfoot },
),
asUint("track") => return self.createHtmlElementT(
Element.Html.Track,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
else => {},
},
6 => switch (@as(u48, @bitCast(name[0..6].*))) {
asUint("script") => return self.createHtmlElementT(
Element.Html.Script,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("button") => return self.createHtmlElementT(
Element.Html.Button,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("canvas") => return self.createHtmlElementT(
Element.Html.Canvas,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("dialog") => return self.createHtmlElementT(
Element.Html.Dialog,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("legend") => return self.createHtmlElementT(
Element.Html.Legend,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("object") => return self.createHtmlElementT(
Element.Html.Object,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("output") => return self.createHtmlElementT(
Element.Html.Output,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("source") => return self.createHtmlElementT(
Element.Html.Source,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("strong") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "strong", .{}) catch unreachable, ._tag = .strong },
),
asUint("header") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "header", .{}) catch unreachable, ._tag = .header },
),
asUint("footer") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "footer", .{}) catch unreachable, ._tag = .footer },
),
asUint("select") => return self.createHtmlElementT(
Element.Html.Select,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("option") => return self.createHtmlElementT(
Element.Html.Option,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("iframe") => return self.createHtmlElementT(
Element.Html.IFrame,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("figure") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "figure", .{}) catch unreachable, ._tag = .figure },
),
asUint("hgroup") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "hgroup", .{}) catch unreachable, ._tag = .hgroup },
),
else => {},
},
7 => switch (@as(u56, @bitCast(name[0..7].*))) {
asUint("section") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "section", .{}) catch unreachable, ._tag = .section },
),
asUint("article") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "article", .{}) catch unreachable, ._tag = .article },
),
asUint("details") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "details", .{}) catch unreachable, ._tag = .details },
),
asUint("summary") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "summary", .{}) catch unreachable, ._tag = .summary },
),
asUint("caption") => return self.createHtmlElementT(
Element.Html.TableCaption,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("marquee") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "marquee", .{}) catch unreachable, ._tag = .marquee },
),
asUint("address") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "address", .{}) catch unreachable, ._tag = .address },
),
asUint("picture") => return self.createHtmlElementT(
Element.Html.Picture,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
else => {},
},
8 => switch (@as(u64, @bitCast(name[0..8].*))) {
asUint("textarea") => return self.createHtmlElementT(
Element.Html.TextArea,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("template") => return self.createHtmlElementT(
Element.Html.Template,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._content = undefined },
),
asUint("colgroup") => return self.createHtmlElementT(
Element.Html.TableCol,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "colgroup", .{}) catch unreachable, ._tag = .colgroup },
),
asUint("fieldset") => return self.createHtmlElementT(
Element.Html.FieldSet,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("optgroup") => return self.createHtmlElementT(
Element.Html.OptGroup,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("progress") => return self.createHtmlElementT(
Element.Html.Progress,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("datalist") => return self.createHtmlElementT(
Element.Html.DataList,
namespace,
attribute_iterator,
.{ ._proto = undefined },
),
asUint("noscript") => return self.createHtmlElementT(
Element.Html.Generic,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "noscript", .{}) catch unreachable, ._tag = .noscript },
),
else => {},
},
10 => switch (@as(u80, @bitCast(name[0..10].*))) {
asUint("blockquote") => return self.createHtmlElementT(
Element.Html.Quote,
namespace,
attribute_iterator,
.{ ._proto = undefined, ._tag_name = String.init(undefined, "blockquote", .{}) catch unreachable, ._tag = .blockquote },
),
else => {},
},
else => {},
}
const tag_name = try String.init(self.arena, name, .{});
// Check if this is a custom element (must have hyphen for HTML namespace)
const has_hyphen = std.mem.indexOfScalar(u8, name, '-') != null;
if (has_hyphen and namespace == .html) {
const definition = self.window._custom_elements._definitions.get(name);
const node = try self.createHtmlElementT(Element.Html.Custom, namespace, attribute_iterator, .{
._proto = undefined,
._tag_name = tag_name,
._definition = definition,
});
const def = definition orelse {
const element = node.as(Element);
const custom = element.is(Element.Html.Custom).?;
try self._undefined_custom_elements.append(self.arena, custom);
return node;
};
// Save and restore upgrading element to allow nested createElement calls
const prev_upgrading = self._upgrading_element;
self._upgrading_element = node;
defer self._upgrading_element = prev_upgrading;
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
var caught: JS.TryCatch.Caught = undefined;
_ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught });
return node;
};
// After constructor runs, invoke attributeChangedCallback for initial attributes
const element = node.as(Element);
if (element._attributes) |attributes| {
var it = attributes.iterator();
while (it.next()) |attr| {
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(
element,
attr._name,
null, // old_value is null for initial attributes
attr._value,
self,
);
}
}
return node;
}
return self.createHtmlElementT(Element.Html.Unknown, namespace, attribute_iterator, .{ ._proto = undefined, ._tag_name = tag_name });
},
.svg => {
const tag_name = try String.init(self.arena, name, .{});
if (std.ascii.eqlIgnoreCase(name, "svg")) {
return self.createSvgElementT(Element.Svg, name, attribute_iterator, .{
._proto = undefined,
._type = .svg,
._tag_name = tag_name,
});
}
// Other SVG elements (rect, circle, text, g, etc.)
const lower = std.ascii.lowerString(&self.buf, name);
const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown;
return self.createSvgElementT(Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag });
},
else => {
const tag_name = try String.init(self.arena, name, .{});
return self.createHtmlElementT(Element.Html.Unknown, namespace, attribute_iterator, .{ ._proto = undefined, ._tag_name = tag_name });
},
}
}
fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespace, attribute_iterator: anytype, html_element: E) !*Node {
const html_element_ptr = try self._factory.htmlElement(html_element);
const element = html_element_ptr.asElement();
element._namespace = namespace;
try self.populateElementAttributes(element, attribute_iterator);
// Check for customized built-in element via "is" attribute
try Element.Html.Custom.checkAndAttachBuiltIn(element, self);
const node = element.asNode();
if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) {
@call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| {
log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err });
return err;
};
}
return node;
}
fn createHtmlMediaElementT(self: *Page, comptime E: type, namespace: Element.Namespace, attribute_iterator: anytype) !*Node {
const media_element = try self._factory.htmlMediaElement(E{ ._proto = undefined });
const element = media_element.asElement();
element._namespace = namespace;
try self.populateElementAttributes(element, attribute_iterator);
return element.asNode();
}
fn createSvgElementT(self: *Page, comptime E: type, tag_name: []const u8, attribute_iterator: anytype, svg_element: E) !*Node {
const svg_element_ptr = try self._factory.svgElement(tag_name, svg_element);
var element = svg_element_ptr.asElement();
element._namespace = .svg;
try self.populateElementAttributes(element, attribute_iterator);
return element.asNode();
}
fn populateElementAttributes(self: *Page, element: *Element, list: anytype) !void {
if (@TypeOf(list) == ?*Element.Attribute.List) {
// from cloneNode
var existing = list orelse return;
var attributes = try self.arena.create(Element.Attribute.List);
attributes.* = .{
.normalize = existing.normalize,
};
var it = existing.iterator();
while (it.next()) |attr| {
try attributes.putNew(attr._name.str(), attr._value.str(), self);
}
element._attributes = attributes;
return;
}
// from the parser
if (@TypeOf(list) == @TypeOf(null) or list.count() == 0) {
return;
}
var attributes = try element.createAttributeList(self);
while (list.next()) |attr| {
try attributes.putNew(attr.name.local.slice(), attr.value.slice(), self);
}
}
pub fn createTextNode(self: *Page, text: []const u8) !*Node {
// might seem unlikely that we get an intern hit, but we'll get some nodes
// with just '\n'
const owned_text = try self.dupeString(text);
const cd = try self._factory.node(CData{
._proto = undefined,
._type = .{ .text = .{
._proto = undefined,
} },
._data = owned_text,
});
cd._type.text._proto = cd;
return cd.asNode();
}
pub fn createComment(self: *Page, text: []const u8) !*Node {
const owned_text = try self.dupeString(text);
const cd = try self._factory.node(CData{
._proto = undefined,
._type = .{ .comment = .{
._proto = undefined,
} },
._data = owned_text,
});
cd._type.comment._proto = cd;
return cd.asNode();
}
pub fn createCDATASection(self: *Page, data: []const u8) !*Node {
// Validate that the data doesn't contain "]]>"
if (std.mem.indexOf(u8, data, "]]>") != null) {
return error.InvalidCharacterError;
}
const owned_data = try self.dupeString(data);
// First allocate the Text node separately
const text_node = try self._factory.create(CData.Text{
._proto = undefined,
});
// Then create the CData with cdata_section variant
const cd = try self._factory.node(CData{
._proto = undefined,
._type = .{ .cdata_section = .{
._proto = text_node,
} },
._data = owned_data,
});
// Set up the back pointer from Text to CData
text_node._proto = cd;
return cd.asNode();
}
pub fn createProcessingInstruction(self: *Page, target: []const u8, data: []const u8) !*Node {
// Validate target doesn't contain "?>"
if (std.mem.indexOf(u8, target, "?>") != null) {
return error.InvalidCharacterError;
}
// Validate target follows XML name rules (similar to attribute name validation)
try Element.Attribute.validateAttributeName(.wrap(target));
const owned_target = try self.dupeString(target);
const owned_data = try self.dupeString(data);
const pi = try self._factory.create(CData.ProcessingInstruction{
._proto = undefined,
._target = owned_target,
});
const cd = try self._factory.node(CData{
._proto = undefined,
._type = .{ .processing_instruction = pi },
._data = owned_data,
});
// Set up the back pointer from ProcessingInstruction to CData
pi._proto = cd;
return cd.asNode();
}
pub fn dupeString(self: *Page, value: []const u8) ![]const u8 {
if (String.intern(value)) |v| {
return v;
}
return self.arena.dupe(u8, value);
}
const RemoveNodeOpts = struct {
will_be_reconnected: bool,
};
pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
// Capture siblings before removing
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
const children = parent._children.?;
switch (children.*) {
.one => |n| {
lp.assert(n == child, "Page.removeNode.one", .{});
parent._children = null;
self._factory.destroy(children);
},
.list => |list| {
list.remove(&child._child_link);
// Should not be possible to get a child list with a single node.
// While it doesn't cause any problems, it indicates an bug in the
// code as these should always be represented as .{.one = node}
const first = list.first.?;
if (first.next == null) {
children.* = .{ .one = Node.linkToNode(first) };
self._factory.destroy(list);
}
},
}
// grab this before we null the parent
const was_connected = child.isConnected();
// Capture the ID map before disconnecting, so we can remove IDs from the correct document
const id_maps = if (was_connected) self.getElementIdMap(child) else null;
child._parent = null;
child._child_link = .{};
// Handle slot assignment removal before mutation observers
if (child.is(Element)) |el| {
// Check if the parent was a shadow host
if (parent.is(Element)) |parent_el| {
if (self._element_shadow_roots.get(parent_el)) |shadow_root| {
// Signal slot changes for any affected slots
const slot_name = el.getAttributeSafe(comptime .wrap("slot")) orelse "";
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(shadow_root.asNode(), .{});
while (tw.next()) |slot_el| {
if (slot_el.is(Element.Html.Slot)) |slot| {
if (std.mem.eql(u8, slot.getName(), slot_name)) {
self.signalSlotChange(slot);
break;
}
}
}
}
}
// Remove from assigned slot lookup
_ = self._element_assigned_slots.remove(el);
}
if (self.hasMutationObservers()) {
const removed = [_]*Node{child};
self.childListChange(parent, &.{}, &removed, previous_sibling, next_sibling);
}
if (opts.will_be_reconnected) {
// We might be removing the node only to re-insert it. If the node will
// remain connected, we can skip the expensive process of fully
// disconnecting it.
return;
}
if (was_connected == false) {
// If the child wasn't connected, then there should be nothing left for
// us to do
return;
}
// The child was connected and now it no longer is. We need to "disconnect"
// it and all of its descendants. For now "disconnect" just means updating
// the ID map and invoking disconnectedCallback for custom elements
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{});
while (tw.next()) |el| {
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
self.removeElementIdWithMaps(id_maps.?, id);
}
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
}
}
pub fn appendNode(self: *Page, parent: *Node, child: *Node, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, .append, opts);
}
pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
self.domChanged();
const dest_connected = target.isConnected();
var it = parent.childrenIterator();
while (it.next()) |child| {
// Check if child was connected BEFORE removing it from parent
const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
try self.appendNode(target, child, .{ .child_already_connected = child_was_connected });
}
}
pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_node: *Node) !void {
self.domChanged();
const dest_connected = parent.isConnected();
var it = fragment.childrenIterator();
while (it.next()) |child| {
// Check if child was connected BEFORE removing it from fragment
const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
try self.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child_was_connected },
);
}
}
const InsertNodeRelative = union(enum) {
append,
after: *Node,
before: *Node,
};
const InsertNodeOpts = struct {
child_already_connected: bool = false,
adopting_to_new_document: bool = false,
};
pub fn insertNodeRelative(self: *Page, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, relative, opts);
}
pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
// caller should have made sure this was the case
lp.assert(child._parent == null, "Page.insertNodeRelative parent", .{ .url = self.url });
const children = blk: {
// expand parent._children so that it can take another child
if (parent._children) |c| {
switch (c.*) {
.list => {},
.one => |node| {
const list = try self._factory.create(std.DoublyLinkedList{});
list.append(&node._child_link);
c.* = .{ .list = list };
},
}
break :blk c;
} else {
const Children = @import("webapi/children.zig").Children;
const c = try self._factory.create(Children{ .one = child });
parent._children = c;
break :blk c;
}
};
switch (relative) {
.append => switch (children.*) {
.one => {}, // already set in the expansion above
.list => |list| list.append(&child._child_link),
},
.after => |ref_node| {
// caller should have made sure this was the case
lp.assert(ref_node._parent.? == parent, "Page.insertNodeRelative after", .{ .url = self.url });
// if ref_node is in parent, and expanded _children above to
// accommodate another child, then `children` must be a list
children.list.insertAfter(&ref_node._child_link, &child._child_link);
},
.before => |ref_node| {
// caller should have made sure this was the case
lp.assert(ref_node._parent.? == parent, "Page.insertNodeRelative before", .{ .url = self.url });
// if ref_node is in parent, and expanded _children above to
// accommodate another child, then `children` must be a list
children.list.insertBefore(&ref_node._child_link, &child._child_link);
},
}
child._parent = parent;
// Tri-state behavior for mutations:
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
// 3. from_parser=false, parse_mode=document -> mutation (js manipulation)
// split like this because from_parser can be comptime known.
const should_notify = if (comptime from_parser)
self._parse_mode == .fragment
else
true;
if (should_notify) {
if (comptime from_parser == false) {
// When the parser adds the node, nodeIsReady is only called when the
// nodeComplete() callback is executed.
try self.nodeIsReady(false, child);
}
// Notify mutation observers about childList change
if (self.hasMutationObservers()) {
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
const added = [_]*Node{child};
self.childListChange(parent, &added, &.{}, previous_sibling, next_sibling);
}
}
if (comptime from_parser) {
if (child.is(Element)) |el| {
// Invoke connectedCallback for custom elements during parsing
// For main document parsing, we know nodes are connected (fast path)
// For fragment parsing (innerHTML), we need to check connectivity
if (child.isConnected() or child.isInShadowTree()) {
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
try self.addElementId(parent, el, id);
}
try Element.Html.Custom.invokeConnectedCallbackOnElement(true, el, self);
}
}
return;
}
// Update slot assignments for the inserted child if parent is a shadow host
// This needs to happen even if the element isn't connected to the document
if (child.is(Element)) |el| {
self.updateElementAssignedSlot(el);
}
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it
return;
}
const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree();
const parent_is_connected = parent.isConnected();
if (!parent_in_shadow and !parent_is_connected) {
return;
}
// If we're here, it means either:
// 1. A disconnected child became connected (parent.isConnected() == true)
// 2. Child is being added to a shadow tree (parent_in_shadow == true)
// In both cases, we need to update ID maps and invoke callbacks
// Only invoke connectedCallback if the root child is transitioning from
// disconnected to connected. When that happens, all descendants should also
// get connectedCallback invoked (they're becoming connected as a group).
const should_invoke_connected = parent_is_connected and !opts.child_already_connected;
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{});
while (tw.next()) |el| {
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
try self.addElementId(el.asNode()._parent.?, el, id);
}
if (should_invoke_connected) {
try Element.Html.Custom.invokeConnectedCallbackOnElement(false, el, self);
}
}
}
pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void {
_ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| {
log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err });
};
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
log.err(.page, "attributeChange.notifyObserver", .{ .err = err });
};
}
// Handle slot assignment changes
if (name.eql(comptime .wrap("slot"))) {
self.updateSlotAssignments(element);
} else if (name.eql(comptime .wrap("name"))) {
// Check if this is a slot element
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
}
}
}
pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void {
_ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| {
log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err });
};
Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self);
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyAttributeChange(element, name, old_value, self) catch |err| {
log.err(.page, "attributeRemove.notifyObserver", .{ .err = err });
};
}
// Handle slot assignment changes
if (name.eql(comptime .wrap("slot"))) {
self.updateSlotAssignments(element);
} else if (name.eql(comptime .wrap("name"))) {
// Check if this is a slot element
if (element.is(Element.Html.Slot)) |slot| {
self.signalSlotChange(slot);
}
}
}
fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void {
self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| {
log.err(.page, "signalSlotChange.put", .{ .err = err });
return;
};
self.scheduleSlotchangeDelivery() catch |err| {
log.err(.page, "signalSlotChange.schedule", .{ .err = err });
};
}
fn updateSlotAssignments(self: *Page, element: *Element) void {
// Find all slots in the shadow root that might be affected
const parent = element.asNode()._parent orelse return;
// Check if parent is a shadow host
const parent_el = parent.is(Element) orelse return;
_ = self._element_shadow_roots.get(parent_el) orelse return;
// Signal change for the old slot (if any)
if (self._element_assigned_slots.get(element)) |old_slot| {
self.signalSlotChange(old_slot);
}
// Update the assignedSlot lookup to the new slot
self.updateElementAssignedSlot(element);
// Signal change for the new slot (if any)
if (self._element_assigned_slots.get(element)) |new_slot| {
self.signalSlotChange(new_slot);
}
}
fn updateElementAssignedSlot(self: *Page, element: *Element) void {
// Remove old assignment
_ = self._element_assigned_slots.remove(element);
// Find the new assigned slot
const parent = element.asNode()._parent orelse return;
const parent_el = parent.is(Element) orelse return;
const shadow_root = self._element_shadow_roots.get(parent_el) orelse return;
const slot_name = element.getAttributeSafe(comptime .wrap("slot")) orelse "";
// Recursively search through the shadow root for a matching slot
if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| {
self._element_assigned_slots.put(self.arena, element, slot) catch |err| {
log.err(.page, "updateElementAssignedSlot.put", .{ .err = err });
};
}
}
fn findMatchingSlot(node: *Node, slot_name: []const u8) ?*Element.Html.Slot {
// Check if this node is a matching slot
if (node.is(Element)) |el| {
if (el.is(Element.Html.Slot)) |slot| {
if (std.mem.eql(u8, slot.getName(), slot_name)) {
return slot;
}
}
}
// Search children
var it = node.childrenIterator();
while (it.next()) |child| {
if (findMatchingSlot(child, slot_name)) |slot| {
return slot;
}
}
return null;
}
pub fn hasMutationObservers(self: *const Page) bool {
return self._mutation_observers.first != null;
}
pub fn getCustomizedBuiltInDefinition(self: *Page, element: *Element) ?*CustomElementDefinition {
return self._customized_builtin_definitions.get(element);
}
pub fn setCustomizedBuiltInDefinition(self: *Page, element: *Element, definition: *CustomElementDefinition) !void {
try self._customized_builtin_definitions.put(self.arena, element, definition);
}
pub fn characterDataChange(
self: *Page,
target: *Node,
old_value: []const u8,
) void {
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyCharacterDataChange(target, old_value, self) catch |err| {
log.err(.page, "cdataChange.notifyObserver", .{ .err = err });
};
}
}
pub fn childListChange(
self: *Page,
target: *Node,
added_nodes: []const *Node,
removed_nodes: []const *Node,
previous_sibling: ?*Node,
next_sibling: ?*Node,
) void {
// Filter out HTML wrapper element during fragment parsing (html5ever quirk)
if (self._parse_mode == .fragment and added_nodes.len == 1) {
if (added_nodes[0].is(Element.Html.Html) != null) {
// This is the temporary HTML wrapper, added by html5ever
// that will be unwrapped, see:
// https://github.com/servo/html5ever/issues/583
return;
}
}
var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first;
while (it) |node| : (it = node.next) {
const observer: *MutationObserver = @fieldParentPtr("node", node);
observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| {
log.err(.page, "childListChange.notifyObserver", .{ .err = err });
};
}
}
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
const previous_parse_mode = self._parse_mode;
self._parse_mode = .fragment;
defer self._parse_mode = previous_parse_mode;
var parser = Parser.init(self.call_arena, node, self);
parser.parseFragment(html);
// https://github.com/servo/html5ever/issues/583
const children = node._children orelse return;
const first = children.one;
lp.assert(first.is(Element.Html.Html) != null, "Page.parseHtmlAsChildren root", .{ .type = first._type });
node._children = first._children;
if (self.hasMutationObservers()) {
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
// Notify mutation observers for each unwrapped child
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
const added = [_]*Node{child};
self.childListChange(node, &added, &.{}, previous_sibling, next_sibling);
}
} else {
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}
}
fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
if ((comptime from_parser) and self._parse_mode == .fragment) {
// we don't execute scripts added via innerHTML = '<script...';
return;
}
if (node.is(Element.Html.Script)) |script| {
if ((comptime from_parser == false) and script._src.len == 0) {
// script was added via JavaScript, but without a src, don't try
// to execute it (we'll execute it if/when the src is set)
return;
}
self.scriptAddedCallback(from_parser, script) catch |err| {
log.err(.page, "page.nodeIsReady", .{ .err = err });
return err;
};
}
}
const ParseState = union(enum) {
pre,
complete,
err: anyerror,
html: std.ArrayList(u8),
text: std.ArrayList(u8),
image: std.ArrayList(u8),
raw: std.ArrayList(u8),
raw_done: []const u8,
};
const LoadState = enum {
// waiting for the main HTML
waiting,
// the main HTML is being parsed (or downloaded)
parsing,
// the main HTML has been parsed and the JavaScript (including deferred
// scripts) have been loaded. Corresponds to the DOMContentLoaded event
load,
// the page has been loaded and all async scripts (if any) are done
// Corresponds to the load event
complete,
};
const IdleNotification = union(enum) {
// hasn't started yet.
init,
// timestamp where the state was first triggered. If the state stays
// true (e.g. 0 nework activity for NetworkIdle, or <= 2 for NetworkAlmostIdle)
// for 500ms, it'll send the notification and transition to .done. If
// the state doesn't stay true, it'll revert to .init.
triggered: u64,
// notification sent - should never be reset
done,
// Returns `true` if we should send a notification. Only returns true if it
// was previously triggered 500+ milliseconds ago.
// active == true when the condition for the notification is true
// active == false when the condition for the notification is false
pub fn check(self: *IdleNotification, active: bool) bool {
if (active) {
switch (self.*) {
.done => {
// Notification was already sent.
},
.init => {
// This is the first time the condition was triggered (or
// the first time after being un-triggered). Record the time
// so that if the condition holds for long enough, we can
// send a notification.
self.* = .{ .triggered = milliTimestamp(.monotonic) };
},
.triggered => |ms| {
// The condition was already triggered and was triggered
// again. When this condition holds for 500+ms, we'll send
// a notification.
if (milliTimestamp(.monotonic) - ms >= 500) {
// This is the only place in this function where we can
// return true. The only place where we can tell our caller
// "send the notification!".
self.* = .done;
return true;
}
// the state hasn't held for 500ms.
},
}
} else {
switch (self.*) {
.done => {
// The condition became false, but we already sent the notification
// There's nothing we can do, it stays .done. We never re-send
// a notification or "undo" a sent notification (not that we can).
},
.init => {
// The condition remains false
},
.triggered => {
// The condition _had_ been true, and we were waiting (500ms)
// for it to hold, but it hasn't. So we go back to waiting.
self.* = .init;
},
}
}
// See above for the only case where we ever return true. All other
// paths go here. This means "don't send the notification". Maybe
// because it's already been sent, maybe because active is false, or
// maybe because the condition hasn't held long enough.
return false;
}
};
pub const NavigateReason = enum {
anchor,
address_bar,
form,
script,
history,
navigation,
};
pub const NavigateOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: Http.Method = .GET,
body: ?[]const u8 = null,
header: ?[:0]const u8 = null,
force: bool = false,
kind: NavigationKind = .{ .push = null },
};
pub const NavigatedOpts = struct {
cdp_id: ?i64 = null,
reason: NavigateReason = .address_bar,
method: Http.Method = .GET,
};
const NavigationPriority = enum {
form,
script,
anchor,
};
const QueuedNavigation = struct {
url: [:0]const u8,
opts: NavigateOpts,
priority: NavigationPriority,
};
pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return;
if (comptime IS_DEBUG) {
log.debug(.page, "page mouse click", .{
.url = self.url,
.node = target,
.x = x,
.y = y,
});
}
const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self)).asEvent();
defer if (!event._v8_handoff) event.deinit(false);
try self._event_manager.dispatch(target.asEventTarget(), event);
}
// callback when the "click" event reaches the pages.
pub fn handleClick(self: *Page, target: *Node) !void {
// TODO: Also support <area> elements when implement
const element = target.is(Element) orelse return;
const html_element = element.is(Element.Html) orelse return;
switch (html_element._type) {
.anchor => |anchor| {
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) {
return;
}
if (std.mem.startsWith(u8, href, "javascript:")) {
return;
}
// Check target attribute - don't navigate if opening in new window/tab
const target_val = anchor.getTarget();
if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) {
log.warn(.not_implemented, "a.target", .{});
return;
}
if (try element.hasAttribute(comptime .wrap("download"), self)) {
log.warn(.browser, "a.download", .{});
return;
}
try element.focus(self);
try self.scheduleNavigation(href, .{
.reason = .script,
.kind = .{ .push = null },
}, .anchor);
},
.input => |input| {
try element.focus(self);
if (input._input_type == .submit) {
return self.submitForm(element, input.getForm(self), .{});
}
},
.button => |button| {
try element.focus(self);
if (std.mem.eql(u8, button.getType(), "submit")) {
return self.submitForm(element, button.getForm(self), .{});
}
},
.select, .textarea => try element.focus(self),
else => {},
}
}
pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent();
defer if (!event._v8_handoff) event.deinit(false);
const element = self.window._document._active_element orelse return;
if (comptime IS_DEBUG) {
log.debug(.page, "page keydown", .{
.url = self.url,
.node = element,
.key = keyboard_event._key,
});
}
try self._event_manager.dispatch(element.asEventTarget(), event);
}
pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void {
const keyboard_event = event.as(KeyboardEvent);
const key = keyboard_event.getKey();
if (key == .Dead) {
return;
}
if (target.is(Element.Html.Input)) |input| {
if (key == .Enter) {
return self.submitForm(input.asElement(), input.getForm(self), .{});
}
// Don't handle text input for radio/checkbox
const input_type = input._input_type;
if (input_type == .radio or input_type == .checkbox) {
return;
}
// Handle printable characters
if (key.isPrintable()) {
try input.innerInsert(key.asString(), self);
}
return;
}
if (target.is(Element.Html.TextArea)) |textarea| {
// zig fmt: off
const append =
if (key == .Enter) "\n"
else if (key.isPrintable()) key.asString()
else return
;
// zig fmt: on
return textarea.innerInsert(append, self);
}
}
const SubmitFormOpts = struct {
fire_event: bool = true,
};
pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void {
const form = form_ orelse return;
if (submitter_) |submitter| {
if (submitter.getAttributeSafe(comptime .wrap("disabled")) != null) {
return;
}
}
if (self.canScheduleNavigation(.form) == false) {
return;
}
const form_element = form.asElement();
if (submit_opts.fire_event) {
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
defer if (!submit_event._v8_handoff) submit_event.deinit(false);
const onsubmit_handler = try form.asHtmlElement().getOnSubmit(self);
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
try self._event_manager.dispatchWithFunction(
form_element.asEventTarget(),
submit_event,
ls.toLocal(onsubmit_handler),
.{ .context = "form submit" },
);
// If the submit event was prevented, don't submit the form
if (submit_event._prevent_default) {
return;
}
}
const FormData = @import("webapi/net/FormData.zig");
// The submitter can be an input box (if enter was entered on the box)
// I don't think this is technically correct, but FormData handles it ok
const form_data = try FormData.init(form, submitter_, self);
const transfer_arena = self._session.transfer_arena;
const encoding = form_element.getAttributeSafe(comptime .wrap("enctype"));
var buf = std.Io.Writer.Allocating.init(transfer_arena);
try form_data.write(encoding, &buf.writer);
const method = form_element.getAttributeSafe(comptime .wrap("method")) orelse "";
var action = form_element.getAttributeSafe(comptime .wrap("action")) orelse self.url;
var opts = NavigateOpts{
.reason = .form,
.kind = .{ .push = null },
};
if (std.ascii.eqlIgnoreCase(method, "post")) {
opts.method = .POST;
opts.body = buf.written();
// form_data.write currently only supports this encoding, so we know this has to be the content type
opts.header = "Content-Type: application/x-www-form-urlencoded";
} else {
action = try URL.concatQueryString(transfer_arena, action, buf.written());
}
return self.scheduleNavigation(action, opts, .form);
}
// insertText is a shortcut to insert text into the active element.
pub fn insertText(self: *Page, v: []const u8) !void {
const html_element = self.document._active_element orelse return;
if (html_element.is(Element.Html.Input)) |input| {
const input_type = input._input_type;
if (input_type == .radio or input_type == .checkbox) {
return;
}
return input.innerInsert(v, self);
}
if (html_element.is(Element.Html.TextArea)) |textarea| {
return textarea.innerInsert(v, self);
}
}
const RequestCookieOpts = struct {
is_http: bool = true,
is_navigation: bool = false,
};
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) Http.Client.RequestCookie {
return .{
.jar = &self._session.cookie_jar,
.origin = self.url,
.is_http = opts.is_http,
.is_navigation = opts.is_navigation,
};
}
fn asUint(comptime string: anytype) std.meta.Int(
.unsigned,
@bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0
) {
const byteLength = @sizeOf(@TypeOf(string.*)) - 1;
const expectedType = *const [byteLength:0]u8;
if (@TypeOf(string) != expectedType) {
@compileError("expected : " ++ @typeName(expectedType) ++ ", got: " ++ @typeName(@TypeOf(string)));
}
return @bitCast(@as(*const [byteLength]u8, string).*);
}
const testing = @import("../testing.zig");
test "WebApi: Page" {
try testing.htmlRunner("page", .{});
}
test "WebApi: Integration" {
try testing.htmlRunner("integration", .{});
}