Merge branch 'main' into c-api

This commit is contained in:
Adrià Arrufat committed 2026-08-11 15:23:37 +02:00
commit f3157935c9
209 files changed
+5057 -1556

No files matched your search

+2 -2
View File
@@ -164,7 +164,7 @@ The output of an agent session is a
[PandaScript](https://lightpanda.io/docs/usage/pandascript): vanilla JavaScript
with a small set of native browser primitives built directly into Lightpanda.
Run `/save` to export one from your current session, then replay it with
`lightpanda agent <script>.js`. Scripts are deterministic and token-free, so
`lightpanda run <script>.js`. Scripts are deterministic and token-free, so
you can prototype with the LLM and ship the output to production without a
model at runtime.
@@ -178,7 +178,7 @@ reference.
./lightpanda agent # auto-detects API key from env
./lightpanda agent --task "top story on news.ycombinator.com?"
./lightpanda agent --no-llm # basic REPL, no LLM
./lightpanda agent session.js # run a recorded script
./lightpanda run session.js # run a recorded script
./lightpanda agent --provider gemini --task "..." # force a specific provider
VERTEX_API_KEY=... ./lightpanda agent --provider vertex # Vertex AI, express mode
GOOGLE_CLOUD_PROJECT=my-proj ./lightpanda agent --provider vertex # Vertex AI, token via gcloud auth
+35 -6
View File
@@ -42,12 +42,39 @@ const Build = blk: {
};
pub fn build(b: *Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// The three settings below only work as a set, so they get one knob rather
// than three defaults: the self-hosted backend needs the shared V8 (it
// cannot apply the CREL relocations in the archive) and needs zig's own CRT
// (a system crt1.o with SFrame unwind data has relocations its linker does
// not handle). Debug-only, opt-in, and a good deal faster to rebuild.
const dev_fast = b.option(bool, "dev_fast", "Linux debug builds: shared V8 + self-hosted backend. Implies -Dshared_v8, -Duse_llvm=false and a bundled-CRT target") orelse false;
const target = if (dev_fast) b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .linux,
.abi = .gnu,
// https://codeberg.org/ziglang/zig/issues/31272
.glibc_version = .{ .major = 2, .minor = 43, .patch = 0 },
}) else b.standardTargetOptions(.{});
if (dev_fast) {
if (builtin.os.tag != .linux) {
std.debug.print("-Ddev_fast is Linux-only (host is {s})\n", .{@tagName(builtin.os.tag)});
return error.DevFastUnsupportedHost;
}
if (optimize != .Debug) {
std.debug.print("-Ddev_fast is Debug-only (optimize is {s})\n", .{@tagName(optimize)});
return error.DevFastRequiresDebug;
}
}
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
const wpt_extensions = b.option(bool, "wpt_extensions", "Extend WebAPI with WPT driver behavior") orelse false;
const shared_v8 = b.option(bool, "shared_v8", "Link V8 as a shared library") orelse dev_fast;
const use_llvm = b.option(bool, "use_llvm", "Use the LLVM backend") orelse !dev_fast;
const version = resolveVersion(b);
std.debug.print("Lightpanda {f}\n", .{version});
@@ -88,7 +115,7 @@ pub fn build(b: *Build) !void {
// Set default behavior
b.default_step.dependOn(fmt_step);
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path, shared_v8);
try linkCurl(b, mod, enable_tsan);
try linkHtml5Ever(b, mod);
linkZenai(b, mod);
@@ -117,7 +144,7 @@ pub fn build(b: *Build) !void {
// browser
const exe = b.addExecutable(.{
.name = "lightpanda",
.use_llvm = true,
.use_llvm = use_llvm,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
@@ -154,7 +181,7 @@ pub fn build(b: *Build) !void {
// snapshot creator
const exe = b.addExecutable(.{
.name = "lightpanda-snapshot-creator",
.use_llvm = true,
.use_llvm = use_llvm,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main_snapshot_creator.zig"),
.target = target,
@@ -184,7 +211,7 @@ pub fn build(b: *Build) !void {
// skills generator
const exe = b.addExecutable(.{
.name = "lightpanda-skills",
.use_llvm = true,
.use_llvm = use_llvm,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main_skills.zig"),
.target = target,
@@ -216,7 +243,7 @@ pub fn build(b: *Build) !void {
// test
const tests = b.addTest(.{
.root_module = lightpanda_module,
.use_llvm = true,
.use_llvm = use_llvm,
.test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
});
const run_tests = b.addRunArtifact(tests);
@@ -352,6 +379,7 @@ fn linkV8(
is_asan: bool,
is_tsan: bool,
prebuilt_v8_path: ?[]const u8,
shared_v8: bool,
) !void {
const target = mod.resolved_target.?;
@@ -364,6 +392,7 @@ fn linkV8(
.v8_enable_sandbox = is_tsan,
.cache_root = b.pathFromRoot(".lp-cache"),
.prebuilt_v8_path = prebuilt_v8_path,
.shared_v8 = shared_v8,
});
mod.addImport("v8", dep.module("v8"));
}
+4 -4
View File
@@ -5,8 +5,8 @@
.minimum_zig_version = "0.16.0",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/508ae5ef169b7202886decd751ccbde1f872e0cb.tar.gz",
.hash = "v8-0.0.0-xddH62D1AgDOizgZ_Il7HVloE_bAK3H4oQnxLcgeKGyW",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/db264d2c4d70c09e102167799e99cebb8a74713f.tar.gz",
.hash = "v8-0.0.0-xddH6wsDAwA_VE-G-JbVC9XnMTGzeuYmzGPmxHpj0KIF",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
@@ -36,8 +36,8 @@
.hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#dcf17cf1c944c8b16e5ecb7419374b134cf47df3",
.hash = "zenai-0.0.0-iOY_VH4NBgA-zH-jOigwD0H4QTUSq1Tvm5qH99XNqd7a",
.url = "git+https://github.com/lightpanda-io/zenai.git#f721fd4171afb95c65182923554d0f1de10d99bf",
.hash = "zenai-0.0.0-iOY_VNgmBgBok23JrHb7N0U67VKSSqRb0BJWIpMMcnoy",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",
+8
View File
@@ -191,6 +191,7 @@ const CommonOptions = .{
.{ .name = "log_filter_scopes", .type = log.FilterRule, .multiple = true, .validator = logFilterScopesValidator },
.{ .name = "user_agent_suffix", .type = ?[]const u8 },
.{ .name = "http_cache_dir", .type = ?[]const u8 },
.{ .name = "http_cache_entry_limit", .type = ?u32, .default = 1000 },
.{ .name = "web_bot_auth_key_file", .type = ?[]const u8 },
.{ .name = "web_bot_auth_keyid", .type = ?[]const u8 },
.{ .name = "web_bot_auth_domain", .type = ?[]const u8 },
@@ -639,6 +640,13 @@ pub fn httpCacheDir(self: *const Config) ?[]const u8 {
};
}
pub fn httpCacheEntryLimit(self: *const Config) u32 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.http_cache_entry_limit.?,
else => 1000,
};
}
pub fn cookieFile(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent, .embed => |opts| opts.cookie,
+5
View File
@@ -19,6 +19,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const posix = std.posix;
const sys_net = @import("sys/net.zig");
const URL = @import("browser/URL.zig");
@@ -76,6 +77,10 @@ pub fn run(self: *TestHTTPServer, wg: *lp.WaitGroup) !void {
fn handleConnection(self: *TestHTTPServer, conn: std.Io.net.Stream) !void {
defer conn.close(lp.io);
if (@hasDecl(posix.TCP, "NODELAY")) {
posix.setsockopt(conn.socket.handle, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))) catch {};
}
var req_buf: [2048]u8 = undefined;
var conn_reader = conn.reader(lp.io, &req_buf);
var conn_writer = conn.writer(lp.io, &req_buf);
+4
View File
@@ -87,6 +87,10 @@ fn runImpl(self: *TestWSServer, wg: *lp.WaitGroup) !void {
fn handleClient(client: posix.socket_t) void {
defer _ = std.c.close(client);
if (@hasDecl(posix.TCP, "NODELAY")) {
posix.setsockopt(client, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))) catch {};
}
var buf: [4096]u8 = undefined;
const n = posix.read(client, &buf) catch return;
+2 -2
View File
@@ -1494,7 +1494,7 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
.text = switch (err) {
error.OutOfMemory => "out of memory",
error.FrameNotLoaded => "no page loaded — run /goto <url> first",
else => std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), @errorName(err) }) catch "tool failed",
else => std.fmt.allocPrint(arena, "{s} failed: {s}", .{ tc.name(), browser_tools.errorMessage(err) }) catch "tool failed",
},
.is_error = true,
};
@@ -1927,7 +1927,7 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
const outcome: zenai.provider.Client.ToolHandler.Result = if (browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments)) |result|
.{ .content = capToolOutput(allocator, tool_name, result.text), .is_error = result.is_error }
else |err|
.{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{@errorName(err)}) catch "Error: tool execution failed", .is_error = true };
.{ .content = std.fmt.allocPrint(allocator, "Error: {s}", .{browser_tools.errorMessage(err)}) catch "Error: tool execution failed", .is_error = true };
self.terminal.agentToolDone(tool_name, args_str, !outcome.is_error);
if (self.terminal.verbosity == .high) self.terminal.printToolOutcome(tool_name, outcome.content, outcome.is_error);
+152 -67
View File
@@ -134,18 +134,24 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
{
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
const target_et = target.asEventTarget();
event._target = target_et;
event._dispatch_target = target_et; // Store original target for composedPath()
// Retarget the relatedTarget against the dispatch target up front
// (DOM dispatch step 4); listeners observe the retargeted value and
// it survives the dispatch.
if (event.relatedTargetPtr()) |related_ptr| {
if (related_ptr.*) |related| {
related_ptr.* = getAdjustedTarget(related, et);
}
// The relatedTarget as authored. Every invocation sees it retargeted
// against its own currentTarget (DOM dispatch step 5.7), so the event
// keeps the unadjusted value between invocations.
const original_related: ?*EventTarget = if (event.relatedTargetPtr()) |p| p.* else null;
event._dispatch_related_target = original_related;
if (original_related) |related| {
if (rootIsShadowRoot(related)) {
event._needs_retargeting = true;
}
// DOM dispatch step 5: an event whose relatedTarget retargets onto the
// target itself isn't dispatched at all.
const adjusted = getAdjustedTarget(related, target_et);
if (adjusted == target_et and related != target_et) {
return;
}
}
@@ -193,8 +199,12 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
related_ptr.* = null;
}
} else if (event._needs_retargeting and node_path_len > 0) {
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
const last = path_buffer[node_path_len - 1];
const adjusted = getAdjustedTarget(event._dispatch_target, last);
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
if (event.relatedTargetPtr()) |related_ptr| {
related_ptr.* = getAdjustedTarget(original_related, last);
}
}
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
@@ -222,38 +232,12 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
}
}
const target_root = target.getRootNode(.{});
var node: ?*Node = target;
while (node) |n| {
if (path_len >= path_buffer.len) break;
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n.is(ShadowRoot)) |shadow| {
event._needs_retargeting = true;
// A non-composed event stops at its own tree's root.
if (!event._composed and n == target_root) {
break;
}
// Otherwise, jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
// an assigned slottable's event-path parent is its assigned slot,
// routing the event into the slot's shadow tree
if (frame._assigned_slots.get(n)) |slot| {
node = slot.asNode();
continue;
}
node = n._parent;
}
const built = buildEventPath(target, event, frame, &path_buffer);
path_len = built.len;
node_path_len = path_len;
if (built.crosses_shadow_root) {
event._needs_retargeting = true;
}
// Even though the window isn't part of the DOM, most events propagate
// through it in the capture phase. It only participates when the tree's
@@ -308,7 +292,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
// Phase 2: At target
if (event._stop_propagation) return;
event._event_phase = .at_target;
const target_et = target.asEventTarget();
blk: {
// Get inline handler (e.g., onclick property) for this target
@@ -320,9 +303,11 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
window._current_event = currentEventForTarget(target_et, event);
defer window._current_event = prev_current_event;
const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, target_et) else null;
// Inline handlers (e.g. onclick property) follow the same "report,
// don't propagate" rule as addEventListener listeners — see Listener.run.
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, target_et, .{event}, &caught) catch |err| ret: {
if (err == error.ExecutionTerminated) {
return error.ExecutionTerminated;
@@ -333,6 +318,10 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
};
processHandlerReturnValue(event, handler_return);
if (adjusted) |a| {
a.restore(event);
}
if (event._stop_propagation) {
return;
}
@@ -377,12 +366,9 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
window._current_event = currentEventForTarget(current_target, event);
defer window._current_event = prev_current_event;
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
}
const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, current_target) else null;
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: {
if (err == error.ExecutionTerminated) {
return error.ExecutionTerminated;
@@ -393,8 +379,8 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event) !void {
};
processHandlerReturnValue(event, handler_return);
if (event._needs_retargeting) {
event._target = original_target;
if (adjusted) |a| {
a.restore(event);
}
if (event._stop_propagation) {
@@ -493,19 +479,15 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._current_target = current_target;
event._in_passive_listener = listener.passive;
// Compute adjusted target for shadow DOM retargeting (only if needed)
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
}
// Compute adjusted targets for shadow DOM retargeting (only if needed)
const adjusted: ?AdjustedTargets = if (event._needs_retargeting) .apply(event, current_target) else null;
try listener.run(frame.call_arena, local, event, "listener");
event._in_passive_listener = false;
// Restore original target (only if we changed it)
if (event._needs_retargeting) {
event._target = original_target;
if (adjusted) |a| {
a.restore(event);
}
if (event._stop_immediate_propagation) {
@@ -547,6 +529,113 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
};
}
// An invocation sees the target and the relatedTarget retargeted against its
// own currentTarget (DOM dispatch step 5.7). The event carries the unadjusted
// values in between, so each invocation adjusts and then restores them.
const AdjustedTargets = struct {
target: ?*EventTarget,
related: ?*EventTarget,
related_ptr: ?*?*EventTarget,
fn apply(event: *Event, current_target: *EventTarget) AdjustedTargets {
const related_ptr = event.relatedTargetPtr();
const original: AdjustedTargets = .{
.target = event._target,
.related = if (related_ptr) |p| p.* else null,
.related_ptr = related_ptr,
};
event._target = getAdjustedTarget(original.target, current_target);
if (related_ptr) |p| {
p.* = getAdjustedTarget(original.related, current_target);
}
return original;
}
fn restore(self: AdjustedTargets, event: *Event) void {
event._target = self.target;
if (self.related_ptr) |p| {
p.* = self.related;
}
}
};
pub const EventPath = struct {
len: usize,
// Whether a shadow root sits on the path, i.e. whether an invocation can
// see a target other than the one the event was dispatched at.
crosses_shadow_root: bool,
};
// Builds the node portion of an event's propagation path (DOM dispatch step
// 5.7) into `buffer`. Window, which follows the document at the end of the
// path, is left to the caller: the rules for including it differ between
// dispatch and composedPath().
pub fn buildEventPath(target: *Node, event: *Event, frame: ?*Frame, buffer: []*EventTarget) EventPath {
if (buffer.len == 0) {
return .{ .len = 0, .crosses_shadow_root = false };
}
const target_root = target.getRootNode(.{});
const related = event._dispatch_related_target;
// The root of the spec's `target` variable, which moves to each host we
// cross on the way out. A node it still contains is inside the current
// target's tree, where the event always propagates; the first node beyond
// it is where the relatedTarget can cut the path short.
var scope_root = target_root;
buffer[0] = target.asEventTarget();
var path: EventPath = .{ .len = 1, .crosses_shadow_root = target.is(ShadowRoot) != null };
var node = eventPathParent(target, event, target_root, frame);
while (node) |n| {
if (path.len == buffer.len) {
break;
}
const et = n.asEventTarget();
if (!isShadowIncludingInclusiveAncestor(scope_root, n)) {
// DOM dispatch step 5.7: the path stops at the relatedTarget.
if (related != null and getAdjustedTarget(related, et) == et) {
break;
}
scope_root = n.getRootNode(.{});
}
if (n.is(ShadowRoot) != null) {
path.crosses_shadow_root = true;
}
buffer[path.len] = et;
path.len += 1;
node = eventPathParent(n, event, target_root, frame);
}
return path;
}
// DOM spec "get the parent" for a node on the event path: an assigned
// slottable's parent is its slot, routing the event into the slot's shadow
// tree, and a shadow root's is its host — except for a non-composed event,
// which stops at the root of the tree it was dispatched in.
fn eventPathParent(node: *Node, event: *Event, target_root: *Node, frame: ?*Frame) ?*Node {
if (node.is(ShadowRoot)) |shadow| {
if (!event._composed and node == target_root) {
return null;
}
return shadow._host.asNode();
}
if (frame) |f| {
if (f._assigned_slots.get(node)) |slot| {
return slot.asNode();
}
}
return node._parent;
}
// DOM spec "retarget": walk original_target out of shadow trees until the
// node is visible from current_target's tree.
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
@@ -589,14 +678,10 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
// shadow root. Used for the spec's post-dispatch "clear targets" step.
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
const target = target_ orelse return false;
var current: *Node = switch (target._type) {
.node => |n| n,
else => return false,
return switch (target._type) {
.node => |n| n.containingShadowRoot() != null,
else => false,
};
while (current._parent) |p| {
current = p;
}
return current.is(ShadowRoot) != null;
}
// Check if ancestor is an ancestor of (or the same as) node
+5 -2
View File
@@ -219,6 +219,7 @@ pub const DispatchError = error{
pub const DispatchDirectOptions = struct {
context: []const u8 = "dispatchDirect",
inject_target: bool = true,
run_microtasks: bool = true,
};
/// Direct dispatch for non-DOM targets. No propagation - just calls the property
@@ -249,7 +250,9 @@ pub fn dispatchDirect(
var ls: js.Local.Scope = undefined;
ctx.localScope(&ls);
defer {
ls.local.runMicrotasks();
if (comptime opts.run_microtasks) {
ls.local.runMicrotasks();
}
ls.deinit();
}
@@ -287,7 +290,7 @@ pub fn dispatchDirect(
// Call the property handler (e.g., onmessage) if present
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
_ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| {
if (err == error.ExecutionTerminated) {
return error.ExecutionTerminated;
+4 -2
View File
@@ -323,7 +323,9 @@ pub fn cdataNode(self: *Factory, cd: Node.CData, leaf: anytype) !*Node.CData {
// only the CDATASection chain has a middle here (its Text)
inline for (3..types.len - 1) |i| {
chain.set(i, .{ ._proto = chain.get(i - 1) });
const ptr = chain.get(i);
ptr.* = .{};
setProto(ptr, chain.get(i - 1));
}
chain.setLeaf(types.len - 1, leaf);
return cd_ptr;
@@ -474,7 +476,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
const svg_ptr = chain.get(i);
svg_ptr.* = .{
._tag_name = try String.init(self._arena, tag_name, .{}),
._type = unionInit(Element.Svg.Type, chain.get(i + 1)),
._type = typeInit(Element.Svg, chain.get(i + 1)),
};
setProto(svg_ptr, chain.get(i - 1));
} else {
+142 -40
View File
@@ -34,6 +34,7 @@ const h5e = @import("parser/html5ever.zig");
const CustomElementReactions = @import("CustomElementReactions.zig");
const URL = @import("URL.zig");
const referrer = @import("referrer.zig");
const Blob = @import("webapi/Blob.zig");
const FileList = @import("webapi/FileList.zig");
const Node = @import("webapi/Node.zig");
@@ -135,9 +136,10 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri
// that actually access these features via JavaScript, saving 24 bytes per element.
_element_styles: Element.StyleLookup = .empty,
// Computed-style views handed out by window.getComputedStyle. The computed
// variant is a stateless lazy view, so one per element suffices — and Chrome
// returns the same object for repeated calls, so identity is also conformance.
_element_computed_styles: Element.StyleLookup = .empty,
// variant is a stateless lazy view, so one per (element, pseudo-element)
// suffices — and Chrome returns the same object for repeated calls, so
// identity is also conformance.
_element_computed_styles: Element.ComputedStyleLookup = .empty,
_element_datasets: Element.DatasetLookup = .empty,
_element_class_lists: Element.ClassListLookup = .empty,
_element_rel_lists: Element.RelListLookup = .empty,
@@ -230,6 +232,10 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele
// The constructor can access this to get the element being upgraded.
_upgrading_element: ?*Node = null,
// _upgrading_element can be consumed once. A second HTMLElement construction
// during upgrade is a TypeError.
_upgrading_consumed: bool = false,
// Set when materializing the fragment parser's context element. The element
// is never inserted into the tree so if its a custom element ,we must not run
// its constructor (else we'll end up in an endless loop if the constructor
@@ -331,6 +337,9 @@ _navigated_options: ?NavigatedOpts = null,
_http_status: ?u16 = null,
_http_headers: std.ArrayList(HttpHeader) = .empty,
_referrer: ?[]const u8 = null,
referrer_policy: referrer.Policy = .default,
pub const HttpHeader = struct {
name: []const u8,
value: []const u8,
@@ -569,6 +578,16 @@ pub fn base(self: *const Frame) [:0]const u8 {
return self.base_url orelse self.url;
}
fn referrerSource(self: *const Frame) [:0]const u8 {
var frame = self;
while (std.mem.startsWith(u8, frame.url, "about:")) {
// about:blank and about:srcdoc documents aren't valid referrer sources,
// use the parents
frame = frame.parent orelse return frame.url;
}
return frame.url;
}
pub fn getTitle(self: *Frame) !?[]const u8 {
if (self.window._document.is(Document.HTMLDocument)) |html_doc| {
return try html_doc.getTitle(self);
@@ -593,8 +612,10 @@ pub fn httpMetadata(self: *const Frame) HttpMetadata {
// Add common headers for a request:
// * referer
pub fn headersForRequest(self: *Frame, transfer: *HttpClient.Transfer) !void {
if (std.mem.startsWith(u8, self.url, "http")) {
try transfer.addHeader("Referer", self.url, .{});
const arena = transfer.arena.allocator();
if (try referrer.compute(arena, self.referrer_policy, self.referrerSource(), transfer.req.url)) |ref| {
try transfer.addHeader("Referer", ref, .{});
transfer.req.referrer_policy = self.referrer_policy;
}
}
@@ -635,11 +656,12 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
const http_client = &session.browser.http_client;
// Handle synthetic navigations: about:blank and blob: URLs
// Handle synthetic navigations: about:blank, about:srcdoc and blob: URLs
const is_about_blank = std.mem.eql(u8, "about:blank", request_url);
const is_blob = !is_about_blank and std.mem.startsWith(u8, request_url, "blob:");
const is_srcdoc = !is_about_blank and std.mem.eql(u8, "about:srcdoc", request_url);
const is_blob = !is_about_blank and !is_srcdoc and std.mem.startsWith(u8, request_url, "blob:");
if (is_about_blank or is_blob) {
if (is_about_blank or is_srcdoc or is_blob) {
if (is_blob) {
if (!Blob.urlBelongsToOrigin(request_url, opts.initiator_origin)) {
log.warn(.js, "invalid blob", .{ .url = request_url });
@@ -647,7 +669,12 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
}
}
self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url);
self.url = if (is_about_blank)
"about:blank"
else if (is_srcdoc)
"about:srcdoc"
else
try self.arena.dupeZ(u8, request_url);
// even though about:blank navigations may share the same _data_, we
// have to do this to make sure window.location is at a unique _address_.
@@ -664,13 +691,17 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
} else if (self.parent) |parent| {
self.origin = parent.origin;
if (is_about_blank) {
if (is_about_blank or is_srcdoc) {
self.base_url = parent.base();
// about:blank and about:srcdoc documents inherit their
// creator's policy container, including the referrer policy
self.referrer_policy = parent.referrer_policy;
}
} else if (self.window._opener) |opener| {
self.origin = opener._frame.origin;
if (is_about_blank) {
self.base_url = opener._frame.base();
self.referrer_policy = opener._frame.referrer_policy;
}
} else {
self.origin = null;
@@ -695,6 +726,30 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
const html = try parse_arena.dupe(u8, blob._slice);
var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true });
parser.parse(html);
} else if (is_srcdoc) {
// The "response body" is the iframe's srcdoc attribute. Only an
// iframe can navigate here (e.g. location = 'about:srcdoc' on a
// root frame ends up with an empty document, like Chrome).
const content = blk: {
const iframe = self.iframe orelse break :blk "";
break :blk iframe.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse "";
};
if (content.len == 0) {
// the parser emits nothing for an empty input; commit the
// same html/head/body scaffolding an empty srcdoc implies
self.document.injectBlank(self) catch |err| {
log.err(.browser, "inject blank", .{ .err = err });
return error.InjectBlankFailed;
};
} else {
const parse_arena = try self.getArena(content.len, "Frame.parseSrcdoc");
defer parse_arena.release();
// A script executed mid-parse can rewrite the srcdoc attribute,
// freeing the value under the parser; parse a copy.
const html = try parse_arena.dupe(u8, content);
var parser = Parser.init(parse_arena.allocator(), self.document.asNode(), self, .{ .allow_declarative_shadow = true });
parser.parse(html);
}
} else {
self.document.injectBlank(self) catch |err| {
log.err(.browser, "inject blank", .{ .err = err });
@@ -742,6 +797,9 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
self._http_status = null;
self._http_headers = .empty;
self._referrer = null;
self.referrer_policy = .default;
self.url = blk: {
if (URL.isCompleteHTTPUrl(request_url)) {
break :blk try self.arena.dupeZ(u8, request_url);
@@ -793,6 +851,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
}
if (opts.referer) |ref| {
try transfer.addHeader("Referer", ref, .{});
self._referrer = try self.arena.dupe(u8, ref);
transfer.req.referrer_policy = opts.referrer_policy;
}
}
@@ -859,7 +919,7 @@ pub fn scheduleNavigation(self: *Frame, request_url: []const u8, opts: NavigateO
// might change inside the function. So the code should be explicit about the
// frame that it's acting on.
fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
const resolved_url, const is_about_blank = blk: {
const resolved_url, const is_about_something = blk: {
if (URL.isCompleteHTTPUrl(request_url)) {
break :blk .{ try arena.dupeZ(u8, request_url), false };
}
@@ -869,6 +929,11 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url
break :blk .{ "about:blank", true };
}
if (std.mem.eql(u8, request_url, "about:srcdoc")) {
// like about:blank, a synchronous navigation handled by navigate
break :blk .{ "about:srcdoc", true };
}
// request_url isn't a "complete" URL, so it has to be resolved with the
// originator's base. Unless, originator's base is "about:blank", in which
// case we have to walk up the parents and find a real base.
@@ -951,20 +1016,17 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url
// Capture the originating frame's URL as the Referer for this
// navigation. The originator's frame may be torn down before navigate()
// runs (processRootQueuedNavigation rebuilds the Page in-place), so dup
// into the QueuedNavigation arena which outlives that tear-down.
// runs (processRootQueuedNavigation rebuilds the Page in-place), so
// allocate from the QueuedNavigation arena which outlives that tear-down.
var nav_opts = opts;
if (std.mem.startsWith(u8, originator.url, "http")) {
// The same dup feeds two purposes: Referer header (subject to
// Referrer-Policy in the future) and SameSite computation (which
// must use the real initiator regardless of policy). We share the
// same allocation for both.
const dup = try arena.dupeZ(u8, originator.url);
const referrer_source = originator.referrerSource();
if (std.mem.startsWith(u8, referrer_source, "http")) {
if (nav_opts.referer == null) {
nav_opts.referer = dup;
nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, referrer_source, resolved_url);
nav_opts.referrer_policy = originator.referrer_policy;
}
if (nav_opts.initiator_url == null) {
nav_opts.initiator_url = dup;
nav_opts.initiator_url = try arena.dupeZ(u8, referrer_source);
}
}
if (nav_opts.initiator_origin == null) {
@@ -978,7 +1040,7 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url
.opts = nav_opts,
.arena = arena,
.url = resolved_url,
.is_about_blank = is_about_blank,
.is_about_something = is_about_something,
.navigation_type = std.meta.activeTag(nt),
};
@@ -1105,8 +1167,8 @@ pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame, delays_load: bool)
.html => true,
else => false,
};
if (parsing_html and iframe._src.len > 0) {
self.queueElementEvent(iframe._proto, .load) catch |err| {
if (parsing_html and (iframe._src.len > 0 or iframe.hasSrcdoc())) {
self.queueElementEvent(Factory.protoOf(iframe), .load) catch |err| {
log.err(.frame, "iframe queue load", .{ .err = err, .url = iframe._src });
};
if (delays_load) {
@@ -1250,6 +1312,13 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
no.body = null;
no.header = null;
}
// The Referer may have been recomputed at each hop; document.referrer
// reports what the final request actually sent.
self._referrer = if (transfer.findRequestHeader("referer")) |ref|
try self.arena.dupe(u8, ref)
else
null;
}
// Init new location.
@@ -1274,6 +1343,11 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.
.name = try self.arena.dupe(u8, hdr.name),
.value = try self.arena.dupe(u8, hdr.value),
});
if (std.ascii.eqlIgnoreCase(hdr.name, "referrer-policy")) {
if (referrer.parseHeader(hdr.value)) |rp| {
self.referrer_policy = rp;
}
}
}
if (self._navigated_options) |no| {
@@ -1773,15 +1847,23 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
return;
}
var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse "";
if (src.len == 0) {
src = "about:blank";
}
const src = blk: {
if (iframe.hasSrcdoc()) {
// srcdoc takes precedence over src, even when empty
break :blk "about:srcdoc";
}
if (URL.isCompleteHTTPUrl(src) and !URL.canParse(src, null)) {
// per spec, if we can't parse the URL, we should load about:blank
src = "about:blank";
}
var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse "";
if (src.len == 0) {
src = "about:blank";
}
if (URL.isCompleteHTTPUrl(src) and !URL.canParse(src, null)) {
// per spec, if we can't parse the URL, we should load about:blank
src = "about:blank";
}
break :blk src;
};
if (iframe._window != null) {
// This frame is being re-navigated. We need to do this through a
@@ -1825,6 +1907,9 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
if (std.mem.eql(u8, src, "about:blank")) {
break :blk "about:blank"; // navigate will handle this special case
}
if (std.mem.eql(u8, src, "about:srcdoc")) {
break :blk "about:srcdoc"; // navigate will handle this special case
}
break :blk try URL.resolve(
self.call_arena, // ok to use, frame.navigate dupes this
self.base(),
@@ -1843,13 +1928,17 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
const was_sorted = self.child_frames_sorted;
self.child_frames_sorted = false;
// Iframe's initial src request carries the parent's URL as Referer and
// as the SameSite initiator. Parent frame outlives this navigate()
// call, so the slice is safe.
const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, self.url, "http")) self.url else null;
// Iframe's initial src request carries the parent's URL as Referer
// (subject to the parent's Referrer-Policy) and as the SameSite
// initiator. When this frame is itself an about: document, the nearest
// ancestor's URL is the referrer source. Parent frame outlives this
// navigate() call, so the slice is safe; navigate dupes what it keeps.
const referrer_source = self.referrerSource();
const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, referrer_source, "http")) referrer_source else null;
new_frame.navigate(url, .{
.reason = .initialFrameNavigation,
.referer = parent_url,
.referer = try referrer.compute(self.call_arena, self.referrer_policy, referrer_source, url),
.referrer_policy = self.referrer_policy,
.initiator_url = parent_url,
.initiator_origin = self.origin,
}) catch |err| {
@@ -2157,7 +2246,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
// this feature is disabled by default, and can be turned on via a command
// line flag or via an CDP command
if (session.load_external_stylesheets == false) {
return self.queueLoad(link._proto);
return self.queueLoad(Factory.protoOf(link));
}
// Fragment-parsed links (innerHTML, DOMParser, ...) may not be attached.
@@ -2489,6 +2578,15 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
return;
}
// Focus goes with the removed subtree. The focused element can be inside a
// shadow tree hanging off it, which the walk below doesn't descend into,
// so ask it directly whether it's still in the document.
if (self.document._active_element) |active| {
if (active.asNode().isConnected() == false) {
self.document._active_element = null;
}
}
// 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
@@ -2720,7 +2818,7 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
return;
}
const parent_in_shadow = parent.is(ShadowRoot) != null or parent.isInShadowTree();
const parent_in_shadow = parent.containingShadowRoot() != null;
if (!parent_in_shadow and !parent_is_connected) {
return;
@@ -3106,6 +3204,10 @@ pub const NavigateOpts = struct {
// anchor click / form submit / location.href navigations carry a Referer.
// null on CDP Page.navigate (address-bar) and Page.reload — matches Chrome.
referer: ?[]const u8 = null,
// The originating frame's policy, paired with `referer` so redirect hops
// can recompute the header. null (e.g. a CDP-supplied referrer) leaves
// the Referer untouched across redirects.
referrer_policy: ?referrer.Policy = null,
// The URL of the document that initiated this navigation, used as the
// "site for cookies" when computing SameSite. Distinct from `referer`
// because a Referrer-Policy can suppress the Referer header without
@@ -3144,7 +3246,7 @@ pub const QueuedNavigation = struct {
arena: *lp.Arena,
url: [:0]const u8,
opts: NavigateOpts,
is_about_blank: bool,
is_about_something: bool, // about:blank or about:srcdoc
navigation_type: NavigationType,
};
+1 -1
View File
@@ -550,7 +550,7 @@ pub fn serialize(arena: Allocator, input: []const u8) ![]const u8 {
// The serialized output is the input length plus quoting overhead; reserve
// the input length so the common (no-escape) case appends without growing.
try out.ensureTotalCapacity(arena, trimmed.len);
try out.ensureTotalCapacityPrecise(arena, trimmed.len);
// Lowercased names already emitted, for first-wins dedupe.
var seen: std.ArrayList([]const u8) = .empty;
+44 -5
View File
@@ -118,10 +118,9 @@ fn _wait(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
const timer: std.Io.Timestamp = .now(io, .boot);
// Periodic V8 GC hint during long waits. V8 is otherwise only nudged on
// session/page teardown (Browser.zig, Page.zig), so a page that stays
// session/page teardown (Session.zig, Page.zig), so a page that stays
// alive for seconds while running heavy JS accumulates wrappers and
// external-ref'd Zig allocations V8 has no reason to drop. `.moderate`
// speeds up incremental GC without stalling the tick.
// external-ref'd Zig allocations V8 has no reason to drop.
const gc_hint_period_ns: u64 = std.time.ns_per_s * 5;
var gc_hint_timer: std.Io.Timestamp = .now(io, .boot);
@@ -231,6 +230,18 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
const network_idle = activity.idle();
const is_done = browser.hasMacrotasks() == false and network_idle;
// Outside the condition loop: it skips resolved conditions, but an idle
// notification needs a check 500ms+ after the hold starts, and on a quiet
// page one tick both starts the hold and resolves the condition. Before
// it, so `.networkidle` conditions read fresh state.
var page_index: usize = 0;
while (page_index < session.pages.items.len) : (page_index += 1) {
// Indexed: notifyNetworkIdle dispatches to listeners.
const page = session.pages.items[page_index];
if (page.replacement != null) continue; // frozen; the replacement is live
page.frame.checkIdleNotifications(total_http_activity);
}
// _we_ have nothing to run, but v8 is working on background tasks. We'll
// wait for them. Don't do this for CDP, since new CDP messages can always
// come in at any time.
@@ -273,8 +284,6 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
}
},
.html, .complete => {
frame.checkIdleNotifications(total_http_activity);
const met = switch (condition.until) {
.done => is_done,
.domcontentloaded => frame._load_state == .load or frame._load_state == .complete,
@@ -297,6 +306,8 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
}
}
// Always taken for is_cdp and every exit returns .ok, so _tick never yields
// .done to the CDP pump: _wait's .done/is_cdp arm is dormant.
if ((comptime is_cdp) or want_http_tick) {
const ms_to_next_task = blk: {
if (has_runnable_page == false) {
@@ -544,3 +555,31 @@ test "Runner: lazy iframe does not delay the load event" {
try testing.expectEqual(true, lazy_child._load_state == .complete);
try testing.expectEqual(true, lazy_child._parent_notified);
}
test "Runner: idle notifications advance past a resolved condition" {
const page = try testing.pageTest("runner/runner1.html", .{});
defer page.close();
const frame = page.frame().?;
// What a quiet page looks like one tick in: the hold has started, and the
// same tick resolved the wait condition. Seeded past the 500ms hold so the
// test doesn't spend it.
const held_since = lp.datetime.milliTimestamp(.boot) -| 600;
frame._notified_network_idle = .{ .triggered = held_since };
frame._notified_network_almost_idle = .{ .triggered = held_since };
var conditions = [_]WaitCondition{.{
.frame_id = page.frame_id,
.until = .done,
.status = .complete,
}};
// is_cdp mirrors CDP.pageWait, which keeps ticking after the condition
// resolves.
var runner = page.session.runner(.{});
_ = try runner._wait(true, 50, &conditions);
try testing.expectEqual(true, frame._notified_network_idle == .done);
try testing.expectEqual(true, frame._notified_network_almost_idle == .done);
}
+2 -2
View File
@@ -760,7 +760,7 @@ pub const Script = struct {
var buffer: std.ArrayList(u8) = .empty;
if (content_length) |cl| {
try buffer.ensureTotalCapacity(self.sourceAllocator(), cl);
try buffer.ensureTotalCapacityPrecise(self.sourceAllocator(), cl);
}
self.source = .{ .remote = buffer };
return .proceed;
@@ -962,7 +962,7 @@ pub const Script = struct {
log.debug(.browser, "executed script", .{ .src = url, .success = success });
}
if (!success and frame.js.env.isExecutionTerminating()) {
if (!success and frame.js.env.terminatePending()) {
return;
}
+4 -4
View File
@@ -598,8 +598,8 @@ fn processPageQueuedNavigation(self: *Session, page: *Page) !void {
continue;
};
if (qn.is_about_blank) {
// Defer about:blank to second pass
if (qn.is_about_something) {
// Defer about:blank or about:srcdoc to second pass
try about_blank_queue.append(self.arena.allocator(), frame);
continue;
}
@@ -637,7 +637,7 @@ fn processPageQueuedNavigation(self: *Session, page: *Page) !void {
while (i < new_navigations.items.len) {
const frame = new_navigations.items[i];
if (frame._queued_navigation) |qn| {
if (qn.is_about_blank) {
if (qn.is_about_something) {
log.warn(.frame, "recursive about blank", .{});
_ = page.queued_navigation.swapRemove(i);
continue;
@@ -783,7 +783,7 @@ fn processRootQueuedNavigation(self: *Session, page: *Page) !void {
// Synthetic navigations (about:blank, blob:) commit instantly — no HTTP,
// so there is no in-flight window to worry about. Use the optimized
// immediate-swap path for them.
const is_synthetic = qn.is_about_blank or std.mem.startsWith(u8, qn.url, "blob:");
const is_synthetic = qn.is_about_something or std.mem.startsWith(u8, qn.url, "blob:");
// The qn arena is consumed here regardless of success — frame.navigate
// dupes the URL into the page's own arena, so we can release the qn
+1 -1
View File
@@ -579,7 +579,7 @@ pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []cons
var buf: std.ArrayList(u8) = .empty;
// the most space well need is the url + ('?' or '&') + the query_string + null terminator
try buf.ensureTotalCapacity(arena, url.len + 2 + query_string.len);
try buf.ensureTotalCapacityPrecise(arena, url.len + 2 + query_string.len);
buf.appendSliceAssumeCapacity(url);
if (std.mem.indexOfScalar(u8, url, '?')) |index| {
+5 -2
View File
@@ -276,7 +276,10 @@ fn remainingMs(timeout_ms: u32, timer: std.Io.Timestamp) u32 {
pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, frame_id: u32, session: *Session) !*DOMNode {
const timer: std.Io.Timestamp = .now(lp.io, .boot);
var runner = session.runner(.{});
try runner.waitForFrame(frame_id, timeout_ms, .{ .until = .load });
// Polling needs a parsed document, nothing more. Gating on `.load` would
// re-wait the late-script tail a `waitUntil: domcontentloaded` navigation
// deliberately skipped.
try runner.waitForFrame(frame_id, timeout_ms, .{ .until = .domcontentloaded });
const el = try runner.waitForSelector(frame_id, selector, remainingMs(timeout_ms, timer));
return el.asNode();
@@ -285,7 +288,7 @@ pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, frame_id: u32, s
pub fn waitForScript(script: [:0]const u8, timeout_ms: u32, frame_id: u32, session: *Session) !void {
const timer: std.Io.Timestamp = .now(lp.io, .boot);
var runner = session.runner(.{});
try runner.waitForFrame(frame_id, timeout_ms, .{ .until = .load });
try runner.waitForFrame(frame_id, timeout_ms, .{ .until = .domcontentloaded });
return runner.waitForScript(frame_id, script, remainingMs(timeout_ms, timer));
}
+1 -1
View File
@@ -261,7 +261,7 @@ fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Fram
fn isVoidElement(el: *const Node.Element) bool {
return switch (el._type) {
.html => |html| switch (html._type) {
.html => switch (el.subtype(Node.Element.Html)._type) {
.br, .hr, .img, .input, .link, .meta => true,
else => false,
},
File diff suppressed because it is too large. Load diff
+67 -40
View File
@@ -204,15 +204,27 @@ fn deltaToScroll(d: f64) i32 {
// implements.
fn hasClickActivationBehavior(node: *Node) bool {
const element = node.is(Element) orelse return false;
const html_element = element.is(Element.Html) orelse return false;
const html_element = element.is(Element.Html) orelse {
if (element.is(Element.Svg.Graphics.A) != null) {
return svgAnchorHref(element) != null;
}
return false;
};
return switch (html_element._type) {
.anchor => element.getAttributeSafe(comptime .wrap("href")) != null,
.input, .button, .select, .textarea, .label => true,
.generic => |generic| generic._tag == .summary,
.generic => html_element.subtype(Element.Html.Generic)._tag == .summary,
else => false,
};
}
// SVG 2 <a> links via `href`; xlink:href is the deprecated SVG 1.1 spelling.
fn svgAnchorHref(element: *Element) ?[]const u8 {
return element.getAttributeSafe(comptime .wrap("href")) orelse element.getAttributeSafe(comptime .wrap("xlink:href"));
}
// Clicks on editable content are for editing: they don't activate the
// element or any enclosing link.
// "contenteditable" is 15 bytes — past the comptime SSO limit — so the
@@ -325,45 +337,23 @@ const JavascriptUrlTask = struct {
pub fn handleClick(frame: *Frame, target: *Node) !void {
// TODO: Also support <area> elements when implement
const element = target.is(Element) orelse return;
if (element.is(Element.Svg.Graphics.A) != null) {
const href = svgAnchorHref(element) orelse return;
const target_name = element.getAttributeSafe(comptime .wrap("target")) orelse "";
return followLink(frame, target, element, href, target_name);
}
const html_element = element.is(Element.Html) orelse return;
switch (html_element._type) {
.anchor => |anchor| {
.anchor => {
const anchor = html_element.subtype(Element.Html.Anchor);
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) {
return;
}
if (std.mem.startsWith(u8, href, "javascript:")) {
// Navigating to a javascript: URL evaluates the script in the
// node's frame as a queued task. (A string completion value
// would replace the document; we ignore results.)
return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]);
}
if (try element.hasAttribute(comptime .wrap("download"), frame)) {
log.warn(.browser, "a.download", .{ .type = frame._type, .url = frame.url });
return;
}
const target_frame = blk: {
const target_name = anchor.getTarget();
if (target_name.len == 0) {
break :blk target.ownerFrame(frame);
}
break :blk frame.resolveTargetFrame(target_name) orelse {
log.warn(.not_implemented, "target", .{ .type = frame._type, .url = frame.url, .target = target_name });
return;
};
};
try element.focus(frame);
try frame.scheduleNavigation(href, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .anchor = target_frame });
return followLink(frame, target, element, href, anchor.getTarget());
},
.input => |input| {
.input => {
const input = html_element.subtype(Element.Html.Input);
try element.focus(frame);
// Per HTML §4.10.18.6.4 "Image Button state (type=image)", clicking an
// image button submits its form. The form-data set already gets the
@@ -373,14 +363,16 @@ pub fn handleClick(frame: *Frame, target: *Node) !void {
return frame.submitForm(element, input.getForm(frame), .{});
}
},
.button => |button| {
.button => {
const button = html_element.subtype(Element.Html.Button);
try element.focus(frame);
if (std.mem.eql(u8, button.getType(), "submit")) {
return frame.submitForm(element, button.getForm(frame), .{});
}
},
.select, .textarea => try element.focus(frame),
.label => |label| {
.label => {
const label = html_element.subtype(Element.Html.Label);
// Per HTML §4.10.4 "The label element", a label's activation
// behavior is to run the synthetic click activation steps on the
// labeled control. Mirrors Chrome's HTMLLabelElement::DefaultEventHandler.
@@ -388,8 +380,8 @@ pub fn handleClick(frame: *Frame, target: *Node) !void {
const control_html = control.is(Element.Html) orelse return;
try control_html.click(frame);
},
.generic => |generic| {
switch (generic._tag) {
.generic => {
switch (html_element.subtype(Element.Html.Generic)._tag) {
.summary => {
const parent_el = target.parentElement() orelse return;
const details = parent_el.is(Element.Html.Details) orelse return;
@@ -410,6 +402,41 @@ pub fn handleClick(frame: *Frame, target: *Node) !void {
}
}
// Follow a link on activation. Shared by HTML <a> and SVG <a>.
fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8, target_name: []const u8) !void {
if (href.len == 0) {
return;
}
if (std.mem.startsWith(u8, href, "javascript:")) {
// Navigating to a javascript: URL evaluates the script in the
// node's frame as a queued task. (A string completion value
// would replace the document; we ignore results.)
return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]);
}
if (try element.hasAttribute(comptime .wrap("download"), frame)) {
log.warn(.browser, "a.download", .{ .type = frame._type, .url = frame.url });
return;
}
const target_frame = blk: {
if (target_name.len == 0) {
break :blk target.ownerFrame(frame);
}
break :blk frame.resolveTargetFrame(target_name) orelse {
log.warn(.not_implemented, "target", .{ .type = frame._type, .url = frame.url, .target = target_name });
return;
};
};
try element.focus(frame);
try frame.scheduleNavigation(href, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .anchor = target_frame });
}
pub fn triggerKeyboard(frame: *Frame, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent();
// Dispatch to the effective active element. When nothing is explicitly
+16 -17
View File
@@ -41,6 +41,8 @@ const Allocator = std.mem.Allocator;
const MAX_CONTEXTS = if (lp.build_config.wpt_extensions) 8192 else 128;
const GC_HINT_FLOOR = 1 * 1024 * 1024;
fn initClassIds() void {
inline for (JsApis, 0..) |JsApi, i| {
JsApi.Meta.class_id = i;
@@ -412,10 +414,7 @@ pub fn runMicrotasks(self: *Env) void {
const v8_isolate = self.isolate.handle;
// terminatePending: once a forcible terminate is requested (and not
// canceled), refuse to start new work — IsExecutionTerminating alone
// goes false again as soon as the killed script finishes unwinding.
if (v8.v8__Isolate__IsExecutionTerminating(v8_isolate) or self.terminatePending()) {
if (self.terminatePending()) {
return;
}
@@ -433,7 +432,7 @@ pub fn runMicrotasks(self: *Env) void {
}
pub fn runMacrotasks(self: *Env) !void {
if (v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle) or self.terminatePending()) {
if (self.terminatePending()) {
return;
}
@@ -512,10 +511,12 @@ pub fn runIdleTasks(self: *const Env) void {
// a Context, it's managed by the garbage collector. We use the
// `memoryPressureNotification` call on the isolate to encourage v8 to free
// any contexts which have been freed.
// The level indicates the aggressivity of the GC required:
// moderate speeds up incremental GC
// critical runs one full GC
// Skips if there's little to reclaim
pub fn memoryPressureNotification(self: *Env, level: Isolate.MemoryPressureLevel) void {
const stats = self.isolate.getHeapStatistics();
if (stats.used_heap_size + stats.external_memory < GC_HINT_FLOOR) {
return;
}
var handle_scope: js.HandleScope = undefined;
handle_scope.init(self.isolate);
defer handle_scope.deinit();
@@ -543,14 +544,11 @@ pub fn dumpMemoryStats(self: *Env) void {
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
}
pub fn isExecutionTerminating(self: *const Env) bool {
return v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle);
}
// Whether a forcible terminate has been requested (and not yet cleared by
// cancelTerminate). Unlike isExecutionTerminating, this is our own sticky
// flag, so it stays true after V8 consumes the terminate on the JSEntry
// unwind. Callers about to enter a fresh eval use it to refuse to run.
// The single "must not run JS" predicate. We're the only ones who ever call
// TerminateExecution (here and in terminateInterrupt) and both set this flag,
// so it's always at least as true as v8__Isolate__IsExecutionTerminating —
// and it stays true after V8 consumes the terminate on the JSEntry unwind,
// which is what stops a fresh eval from re-entering mid-teardown.
pub fn terminatePending(self: *const Env) bool {
return self.terminate_requested.load(.acquire);
}
@@ -558,6 +556,7 @@ pub fn terminatePending(self: *const Env) bool {
pub fn terminate(self: *Env) void {
self.terminate_mutex.lockUncancelable(lp.io);
defer self.terminate_mutex.unlock(lp.io);
self.terminate_requested.store(true, .release);
v8.v8__Isolate__TerminateExecution(self.isolate.handle);
}
@@ -632,7 +631,7 @@ pub fn cancelTerminate(self: *Env) void {
pub fn performIsolateMicrotasks(self: *Env) void {
self.terminate_mutex.lockUncancelable(lp.io);
defer self.terminate_mutex.unlock(lp.io);
if (v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle)) return;
if (self.terminatePending()) return;
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.isolate.handle);
}
+36 -19
View File
@@ -49,6 +49,21 @@ pub fn withThis(self: *const Function, value: anytype) !Function {
}
pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Object {
var try_catch: js.TryCatch = undefined;
try_catch.init(self.local);
defer try_catch.deinit();
return self.newInstanceThrow() catch |err| {
if (err == error.JsConstructorFailed) {
caught.* = try_catch.caughtOrError(self.local.call_arena, error.Unknown);
}
return err;
};
}
// Like newInstance, but with no TryCatch of our own. Gives more flexibility to
// the caller on how to handle the error (e.g. window.reportError)
pub fn newInstanceThrow(self: *const Function) !js.Object {
const local = self.local;
if (comptime lp.IS_DEBUG == false) {
@@ -66,21 +81,16 @@ pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Objec
}
// See _tryCallWithThis for why a pending termination blocks V8 entry.
if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) {
if (local.ctx.env.terminatePending()) {
return error.ExecutionTerminated;
}
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
// This creates a new instance using this Function as a constructor.
// const c_args = @as(?[*]const ?*c.Value, @ptrCast(&.{}));
const handle = v8.v8__Function__NewInstance(self.handle, local.handle, 0, null) orelse {
if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) {
if (local.ctx.env.terminatePending()) {
return error.ExecutionTerminated;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.Unknown);
return error.JsConstructorFailed;
};
@@ -91,7 +101,7 @@ pub fn newInstance(self: *const Function, caught: *js.TryCatch.Caught) !js.Objec
}
pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{}) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
return err;
@@ -99,7 +109,7 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
}
pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
if (err != error.TryCatchRethrow) {
// error.TryCatchRethrow is a control flow (sorry!), not an actual
@@ -111,7 +121,7 @@ pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
}
pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
return self._tryCallWithThis(T, this, args, &caught, .{}) catch |err| {
log.warn(.js, "callWithThis caught", .{ .err = err, .caught = caught });
return err;
@@ -122,7 +132,7 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
// TryCatch, so an enclosing TryCatch of the caller can observe the exception
// value itself (e.g. to report it to window.onerror).
pub fn callWithThisRethrow(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
return self._tryCallWithThis(T, this, args, &caught, .{ .rethrow = true });
}
@@ -138,7 +148,6 @@ const CallOpts = struct {
rethrow: bool = false,
};
fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype, caught: *js.TryCatch.Caught, comptime opts: CallOpts) !T {
caught.* = .{};
const local = self.local;
if (comptime lp.IS_DEBUG == false) {
@@ -158,7 +167,7 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
// A pending termination (watchdog / CDP-disconnect kill) must not be
// followed by another V8 entry. Callers must treat ExecutionTerminated as
// stop running JS and unwind".
if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) {
if (local.ctx.env.terminatePending()) {
return error.ExecutionTerminated;
}
@@ -212,7 +221,7 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
defer try_catch.deinit();
const handle = v8.v8__Function__Call(self.handle, local.handle, js_this.handle, @as(c_int, @intCast(js_args.len)), c_args) orelse {
if (v8.v8__Isolate__IsExecutionTerminating(local.isolate.handle)) {
if (local.ctx.env.terminatePending()) {
// Terminated mid-call, not a JS throw: no rethrow, no reporting.
return error.ExecutionTerminated;
}
@@ -265,7 +274,7 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
}
const testing = @import("../../testing.zig");
test "Function: termination is classified and blocks re-entry" {
test "Function: requested termination is classified and blocks re-entry" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
@@ -284,9 +293,10 @@ test "Function: termination is classified and blocks re-entry" {
probe_ran: bool = false,
kill_err: ?anyerror = null,
probe_err: ?anyerror = null,
nested_probe_err: ?anyerror = null,
fn kill(self: *@This()) void {
self.env.terminate();
self.env.requestTerminate();
}
fn probed(self: *@This()) void {
@@ -297,12 +307,12 @@ test "Function: termination is classified and blocks re-entry" {
// termination pending, and the follow-up call must refuse to enter V8
// (running it would silently clear the pending termination).
fn nested(self: *@This()) void {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
_ = self.f_kill.?.tryCall(void, .{}, &caught) catch |err| {
self.kill_err = err;
};
_ = self.f_probe.?.tryCall(void, .{}, &caught) catch |err| {
self.probe_err = err;
self.nested_probe_err = err;
};
}
};
@@ -319,9 +329,16 @@ test "Function: termination is classified and blocks re-entry" {
const driver = try local.exec("(function(n){ n(); })", null);
const driver_fn = Function{ .local = local, .handle = @ptrCast(driver.handle) };
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
try testing.expectError(error.ExecutionTerminated, driver_fn.tryCall(void, .{nested_cb}, &caught));
try testing.expectEqual(error.ExecutionTerminated, state.kill_err.?);
try testing.expectEqual(error.ExecutionTerminated, state.nested_probe_err.?);
try testing.expectEqual(true, env.terminatePending());
try testing.expectEqual(false, v8.v8__Isolate__IsExecutionTerminating(env.isolate.handle));
_ = state.f_probe.?.tryCall(void, .{}, &caught) catch |err| {
state.probe_err = err;
};
try testing.expectEqual(error.ExecutionTerminated, state.probe_err.?);
try testing.expectEqual(false, state.probe_ran);
+9 -1
View File
@@ -1267,7 +1267,15 @@ pub fn resolveValue(value: anytype) Resolved {
// (e.g. CData); the type maps the tag to the member's type.
if (comptime @typeInfo(@TypeOf(value._type)) == .@"enum" and @hasDecl(T, "Subtype")) {
switch (value._type) {
inline else => |tag| return resolveValue(value.subtype(T.Subtype(tag))),
inline else => |tag| {
const S = T.Subtype(tag);
if (S == T) {
// A tag can map to the type itself (e.g. Media.generic);
// the value is already the most specific type.
return resolveT(T, value);
}
return resolveValue(value.subtype(S));
},
}
}
+3 -3
View File
@@ -32,12 +32,12 @@ handle: *const v8.Script,
pub fn run(self: Script) !js.Value {
// See Function._tryCallWithThis for why a pending termination blocks V8
// entry and is distinct from a JS throw.
const isolate_handle = self.local.isolate.handle;
if (v8.v8__Isolate__IsExecutionTerminating(isolate_handle)) {
const env = self.local.ctx.env;
if (env.terminatePending()) {
return error.ExecutionTerminated;
}
const result = v8.v8__Script__Run(self.handle, self.local.handle) orelse {
if (v8.v8__Isolate__IsExecutionTerminating(isolate_handle)) {
if (env.terminatePending()) {
return error.ExecutionTerminated;
}
return error.JsException;
+3 -3
View File
@@ -133,7 +133,7 @@ fn appendTextChunk(self: *Parser, parent: *Node, txt: []const u8) !void {
// Existing text sibling without a matching pending_text. Seed the
// buf from its _data and register pending so subsequent chunks
// accumulate cheaply.
const cdata = tn._proto;
const cdata = tn.asCData();
const existing = cdata.getData().str();
try self.buf.ensureTotalCapacity(self.arena, existing.len + txt.len);
self.buf.appendSliceAssumeCapacity(existing);
@@ -149,7 +149,7 @@ fn appendTextChunk(self: *Parser, parent: *Node, txt: []const u8) !void {
try self.frame.appendNew(parent, new_text);
self.pending_text = .{
.parent = parent,
.text_node = new_text.is(CData.Text).?._proto,
.text_node = new_text.is(CData.Text).?.asCData(),
};
}
@@ -600,7 +600,7 @@ fn getTemplateContentsCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv
fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
const element = node.as(Element);
const template = element._type.html.is(Element.Html.Template) orelse unreachable;
const template = element.subtype(Element.Html).is(Element.Html.Template) orelse unreachable;
const content_node = template.getContent().asNode();
// Create a ParsedNode wrapper for the content DocumentFragment
+222
View File
@@ -0,0 +1,222 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Referrer Policy: https://www.w3.org/TR/referrer-policy/
const std = @import("std");
const URL = @import("URL.zig");
const Allocator = std.mem.Allocator;
pub const Policy = enum {
no_referrer,
no_referrer_when_downgrade,
origin,
origin_when_cross_origin,
same_origin,
strict_origin,
strict_origin_when_cross_origin,
unsafe_url,
pub const default: Policy = .strict_origin_when_cross_origin;
};
pub fn parse(value: []const u8) ?Policy {
const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{
.{ "no-referrer", Policy.no_referrer },
.{ "no-referrer-when-downgrade", Policy.no_referrer_when_downgrade },
.{ "origin", Policy.origin },
.{ "origin-when-cross-origin", Policy.origin_when_cross_origin },
.{ "same-origin", Policy.same_origin },
.{ "strict-origin", Policy.strict_origin },
.{ "strict-origin-when-cross-origin", Policy.strict_origin_when_cross_origin },
.{ "unsafe-url", Policy.unsafe_url },
});
return map.get(value);
}
pub fn parseHeader(value: []const u8) ?Policy {
var policy: ?Policy = null;
var it = std.mem.splitScalar(u8, value, ',');
while (it.next()) |token| {
if (parse(std.mem.trim(u8, token, " \t"))) |p| {
policy = p;
}
}
return policy;
}
pub fn parseMeta(value: []const u8) ?Policy {
if (parse(value)) |p| {
return p;
}
// legacy values
const map = std.StaticStringMapWithEql(Policy, staticStringMapEqlAsciiIgnoreCase).initComptime(.{
.{ "never", Policy.no_referrer },
.{ "always", Policy.unsafe_url },
.{ "origin-when-crossorigin", Policy.origin_when_cross_origin },
.{ "default", Policy.default },
});
return map.get(value);
}
// returns the value to send as the Referrer header based on the target_url
// and the given policy
pub fn compute(arena: Allocator, policy: Policy, referrer_url: [:0]const u8, target_url: [:0]const u8) !?[]const u8 {
if (policy == .no_referrer) {
return null;
}
const referrer_origin = (try URL.getOrigin(arena, referrer_url)) orelse {
// blob:, data: ... don't set get a referrer
return null;
};
const same_origin = blk: {
const target_origin = (try URL.getOrigin(arena, target_url)) orelse break :blk false;
break :blk std.ascii.eqlIgnoreCase(referrer_origin, target_origin);
};
const downgrade = URL.isSecure(referrer_url) and !URL.isSecure(target_url);
const full = switch (policy) {
.no_referrer => unreachable,
.unsafe_url => true,
.origin => false,
.no_referrer_when_downgrade => if (downgrade) return null else true,
.same_origin => if (same_origin) true else return null,
.origin_when_cross_origin => same_origin,
.strict_origin => if (downgrade) return null else false,
.strict_origin_when_cross_origin => if (same_origin) true else if (downgrade) return null else false,
};
if (full) {
// Serializing through origin + path + query strips credentials and
// the fragment, and normalizes away default ports.
const value = try std.fmt.allocPrint(arena, "{s}{s}{s}", .{
referrer_origin,
URL.getPathname(referrer_url),
URL.getSearch(referrer_url),
});
if (value.len <= 4096) {
// spec limit, if it's more than this, we falllback to the origin
return value;
}
}
return try std.fmt.allocPrint(arena, "{s}/", .{referrer_origin});
}
fn staticStringMapEqlAsciiIgnoreCase(a: []const u8, b: []const u8) bool {
for (a, b) |a_c, b_c| {
if (std.ascii.toLower(a_c) != std.ascii.toLower(b_c)) {
return false;
}
}
return true;
}
const testing = @import("../testing.zig");
test "referrer: parse" {
try testing.expectEqual(Policy.no_referrer, parse("no-referrer"));
try testing.expectEqual(Policy.unsafe_url, parse("Unsafe-URL"));
try testing.expectEqual(null, parse(""));
try testing.expectEqual(null, parse("never"));
try testing.expectEqual(null, parse("no-referrer "));
try testing.expectEqual(null, parseHeader(""));
try testing.expectEqual(null, parseHeader("nope"));
try testing.expectEqual(Policy.origin, parseHeader("origin"));
try testing.expectEqual(Policy.same_origin, parseHeader("origin, same-origin"));
try testing.expectEqual(Policy.origin, parseHeader("origin, garbage"));
try testing.expectEqual(Policy.same_origin, parseHeader(" origin ,\tsame-origin "));
try testing.expectEqual(Policy.no_referrer, parseMeta("never"));
try testing.expectEqual(Policy.unsafe_url, parseMeta("always"));
try testing.expectEqual(Policy.origin_when_cross_origin, parseMeta("origin-when-crossorigin"));
try testing.expectEqual(Policy.strict_origin_when_cross_origin, parseMeta("default"));
try testing.expectEqual(Policy.origin, parseMeta("origin"));
try testing.expectEqual(null, parseMeta("garbage"));
}
test "referrer: compute" {
const Case = struct {
policy: Policy,
referrer: [:0]const u8,
target: [:0]const u8,
expected: ?[]const u8,
};
const cases = [_]Case{
.{ .policy = .no_referrer, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = null },
.{ .policy = .unsafe_url, .referrer = "http://a.com/p?q=1#frag", .target = "https://b.com/", .expected = "http://a.com/p?q=1" },
.{ .policy = .unsafe_url, .referrer = "https://a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" },
.{ .policy = .unsafe_url, .referrer = "https://user:pass@a.com/p", .target = "http://b.com/", .expected = "https://a.com/p" },
.{ .policy = .unsafe_url, .referrer = "https://a.com:443/p", .target = "http://b.com/", .expected = "https://a.com/p" },
.{ .policy = .unsafe_url, .referrer = "http://a.com", .target = "http://b.com/", .expected = "http://a.com/" },
.{ .policy = .origin, .referrer = "http://a.com:8000/p?q=1", .target = "http://a.com:8000/x", .expected = "http://a.com:8000/" },
.{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" },
.{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = null },
.{ .policy = .same_origin, .referrer = "http://a.com/p", .target = "https://a.com/x", .expected = null },
.{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://a.com/x", .expected = "http://a.com/p" },
.{ .policy = .origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" },
.{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null },
.{ .policy = .strict_origin, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/" },
.{ .policy = .strict_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" },
.{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null },
.{ .policy = .no_referrer_when_downgrade, .referrer = "https://a.com/p", .target = "https://b.com/x", .expected = "https://a.com/p" },
.{ .policy = .no_referrer_when_downgrade, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/p" },
.{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p?q=1", .target = "http://a.com/x", .expected = "http://a.com/p?q=1" },
.{ .policy = .strict_origin_when_cross_origin, .referrer = "http://a.com/p", .target = "http://b.com/x", .expected = "http://a.com/" },
.{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://b.com/x", .expected = null },
.{ .policy = .strict_origin_when_cross_origin, .referrer = "https://a.com/p", .target = "http://a.com/x", .expected = null },
// no referrer from non-http(s) documents
.{ .policy = .unsafe_url, .referrer = "about:blank", .target = "http://b.com/x", .expected = null },
.{ .policy = .unsafe_url, .referrer = "data:text/html,x", .target = "http://b.com/x", .expected = null },
};
for (cases) |case| {
const actual = try compute(testing.arena_allocator, case.policy, case.referrer, case.target);
if (case.expected) |expected| {
try testing.expectEqual(expected, actual orelse return error.UnexpectedNull);
} else {
try testing.expectEqual(null, actual);
}
}
}
test "referrer: compute caps at 4096 bytes" {
const path = "/" ++ ("a" ** 4096);
const url = "http://a.com" ++ path;
// over the cap: falls back to the origin form
try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .unsafe_url, url, "http://b.com/x")).?);
try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .no_referrer_when_downgrade, url, "http://a.com/x")).?);
// exactly at the cap: sent in full
const at_cap = "http://a.com/" ++ ("a" ** (4096 - "http://a.com/".len));
try testing.expectEqual(at_cap, (try compute(testing.arena_allocator, .unsafe_url, at_cap, "http://b.com/x")).?);
// origin-only policies are unaffected by the referrer's length
try testing.expectEqual("http://a.com/", (try compute(testing.arena_allocator, .origin, url, "http://b.com/x")).?);
}
@@ -20,9 +20,13 @@
}
customElements.define('ce-ctor-innerhtml', CeCtorInnerHtml);
// Adding children during construction violates a post-condition of
// "create an element", so the result is a fallback HTMLUnknownElement
// rather than the constructed instance. What matters here is that the
// constructor ran exactly once instead of recursing.
const el = document.createElement('ce-ctor-innerhtml');
testing.expectEqual(1, calls);
testing.expectEqual('<span>hi</span>', el.innerHTML);
testing.expectEqual(true, el instanceof HTMLUnknownElement);
}
</script>
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="uses_constructor_return_value">
{
// https://dom.spec.whatwg.org/#concept-create-element -- creation does
// not push onto the construction stack, so super() mints its own
// element and the element we keep is whatever the constructor returns.
let returned = null;
class ReturnsAnother extends HTMLElement {
constructor() {
super();
if (returned === null) {
returned = document.createElement('div');
// A div is not a valid result for this token, so keep it
// simple: return a second instance of ourselves instead.
returned = Reflect.construct(HTMLElement, [], ReturnsAnother);
return returned;
}
}
}
customElements.define('ce-returns-another', ReturnsAnother);
const el = document.createElement('ce-returns-another');
testing.expectEqual(true, el === returned);
}
</script>
<script id="rejects_element_with_children">
{
class AddsChild extends HTMLElement {
constructor() {
super();
this.appendChild(document.createElement('span'));
}
}
customElements.define('ce-adds-child', AddsChild);
const el = document.createElement('ce-adds-child');
testing.expectEqual(true, el instanceof HTMLUnknownElement);
testing.expectEqual('ce-adds-child', el.localName);
}
</script>
<script id="allows_child_added_then_removed">
{
// The post-condition is on the final state, not on what the
// constructor touched along the way.
class AddsAndRemoves extends HTMLElement {
constructor() {
super();
this.appendChild(document.createElement('span'));
this.removeChild(this.firstChild);
}
}
customElements.define('ce-adds-and-removes', AddsAndRemoves);
const el = document.createElement('ce-adds-and-removes');
testing.expectEqual(true, el instanceof AddsAndRemoves);
}
</script>
<script id="rejects_element_with_attribute">
{
class AddsAttribute extends HTMLElement {
constructor() {
super();
this.setAttribute('id', 'nope');
}
}
customElements.define('ce-adds-attribute', AddsAttribute);
testing.expectEqual(true, document.createElement('ce-adds-attribute') instanceof HTMLUnknownElement);
}
</script>
<script id="rejects_parented_element">
{
// The case that crashed the parser: a constructor that attaches its own
// element leaves a node with a parent, which the tree builder would
// then try to append a second time.
const host = document.createElement('div');
class AttachesSelf extends HTMLElement {
constructor() {
super();
host.appendChild(this);
}
}
customElements.define('ce-attaches-self', AttachesSelf);
const el = document.createElement('ce-attaches-self');
testing.expectEqual(true, el instanceof HTMLUnknownElement);
testing.expectEqual(null, el.parentNode);
}
</script>
<script id="parser_falls_back_without_crashing">
{
// Same thing, but driven through the fragment parser rather than
// createElement: the fallback element is what gets inserted.
const host = document.createElement('div');
class ParserAttachesSelf extends HTMLElement {
constructor() {
super();
host.appendChild(this);
}
}
customElements.define('ce-parser-attaches-self', ParserAttachesSelf);
const container = document.createElement('div');
container.innerHTML = '<ce-parser-attaches-self></ce-parser-attaches-self>';
testing.expectEqual(1, container.children.length);
testing.expectEqual(true, container.firstElementChild instanceof HTMLUnknownElement);
}
</script>
<script id="attributes_applied_after_constructor">
{
// The constructor must not observe the token's attributes; they are
// applied afterwards, which is also what enqueues their reactions.
let seen = 'unset';
class ObservesAttribute extends HTMLElement {
static get observedAttributes() { return ['title']; }
constructor() {
super();
seen = this.getAttribute('title');
}
}
customElements.define('ce-observes-attribute', ObservesAttribute);
const container = document.createElement('div');
container.innerHTML = '<ce-observes-attribute title="after"></ce-observes-attribute>';
testing.expectEqual(null, seen);
testing.expectEqual('after', container.firstElementChild.getAttribute('title'));
}
</script>
@@ -0,0 +1,286 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="create_element_reports_thrown_exception">
{
// https://dom.spec.whatwg.org/#concept-create-element -- a failed
// synchronous construction is reported to the global, and the very
// exception object must reach window.onerror.
const thrown = new Error('from constructor');
class ThrowsInConstructor extends HTMLElement {
constructor() {
super();
throw thrown;
}
}
customElements.define('ce-report-throw', ThrowsInConstructor);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
const el = document.createElement('ce-report-throw');
window.onerror = null;
testing.expectEqual(true, reported === thrown);
testing.expectEqual(true, el instanceof HTMLUnknownElement);
}
</script>
<script id="create_element_reports_type_error_for_bad_return">
{
class ReturnsObject extends HTMLElement {
constructor() {
super();
return {};
}
}
customElements.define('ce-report-bad-return', ReturnsObject);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
document.createElement('ce-report-bad-return');
window.onerror = null;
testing.expectEqual('TypeError', reported.name);
}
</script>
<script id="create_element_reports_not_supported_for_state_violation">
{
class AddsAttribute extends HTMLElement {
constructor() {
super();
this.setAttribute('id', 'nope');
}
}
customElements.define('ce-report-attribute', AddsAttribute);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
document.createElement('ce-report-attribute');
window.onerror = null;
testing.expectEqual('NotSupportedError', reported.name);
}
</script>
<script id="create_element_rejects_adoption_into_another_document">
{
const other = document.implementation.createHTMLDocument();
class AdoptsSelf extends HTMLElement {
constructor() {
super();
other.adoptNode(this);
}
}
customElements.define('ce-report-adopts-self', AdoptsSelf);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
const el = document.createElement('ce-report-adopts-self');
window.onerror = null;
testing.expectEqual('NotSupportedError', reported.name);
testing.expectEqual(true, el instanceof HTMLUnknownElement);
}
</script>
<script id="upgrade_rejects_constructor_returning_another_element">
{
// https://html.spec.whatwg.org/#upgrades -- the construction result
// must be SameValue with the element being upgraded.
const a = document.createElement('ce-report-same-value');
const b = document.createElement('ce-report-same-value');
document.documentElement.appendChild(a);
document.documentElement.appendChild(b);
class ReturnsOther extends HTMLElement {
constructor() {
super();
if (this === a) {
return b;
}
}
}
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
customElements.define('ce-report-same-value', ReturnsOther);
window.onerror = null;
testing.expectEqual('TypeError', reported.name);
// a's failed upgrade must not prevent b's.
testing.expectEqual(true, b instanceof ReturnsOther);
}
</script>
<script id="upgrade_rejects_self_instantiation">
{
// The construction stack's "already constructed" marker: a constructor
// instantiating itself during its own upgrade is a TypeError.
class InstantiatesItself extends HTMLElement {
constructor(stop) {
super();
if (!stop) {
new InstantiatesItself(true);
}
}
}
const el = document.createElement('ce-instantiates-itself');
document.documentElement.appendChild(el);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
customElements.define('ce-instantiates-itself', InstantiatesItself);
window.onerror = null;
testing.expectEqual('TypeError', reported.name);
}
</script>
<script id="failed_upgrade_is_not_retried">
{
let attempts = 0;
class FailsToUpgrade extends HTMLElement {
constructor() {
super();
attempts += 1;
throw new Error('nope');
}
}
const el = document.createElement('ce-fails-to-upgrade');
document.documentElement.appendChild(el);
window.onerror = () => true;
customElements.define('ce-fails-to-upgrade', FailsToUpgrade);
// Reconnecting a "failed" element must not attempt another upgrade.
el.remove();
document.documentElement.appendChild(el);
window.onerror = null;
testing.expectEqual(1, attempts);
}
</script>
<script id="parser_reports_constructor_exception">
{
// The parser's create-element path must report too, and still insert
// the HTMLUnknownElement fallback.
const thrown = new Error('from parser constructor');
class ParserThrows extends HTMLElement {
constructor() {
super();
throw thrown;
}
}
customElements.define('ce-parser-throws', ParserThrows);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
const container = document.createElement('div');
container.innerHTML = '<ce-parser-throws></ce-parser-throws>';
window.onerror = null;
testing.expectEqual(true, reported === thrown);
testing.expectEqual(1, container.children.length);
testing.expectEqual(true, container.firstElementChild instanceof HTMLUnknownElement);
}
</script>
<script id="upgrade_rejects_self_instantiation_before_super">
{
// The before-super variant: the nested construction consumes the
// upgrading element, so the outer super() finds the marker set.
class InstantiatesBeforeSuper extends HTMLElement {
constructor(stop) {
if (!stop) {
new InstantiatesBeforeSuper(true);
}
super();
}
}
const el = document.createElement('ce-before-super');
document.documentElement.appendChild(el);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
customElements.define('ce-before-super', InstantiatesBeforeSuper);
window.onerror = null;
testing.expectEqual('TypeError', reported.name);
}
</script>
<script id="construction_during_upgrade_is_isolated">
{
// createElement of another custom element inside an upgrading
// constructor must not inherit the outer construction's
// already-constructed marker.
class Inner extends HTMLElement {}
customElements.define('ce-marker-inner', Inner);
let inner = null;
class Outer extends HTMLElement {
constructor() {
super();
inner = document.createElement('ce-marker-inner');
}
}
const el = document.createElement('ce-marker-outer');
document.documentElement.appendChild(el);
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
customElements.define('ce-marker-outer', Outer);
window.onerror = null;
testing.expectEqual(null, reported);
testing.expectEqual(true, inner instanceof Inner);
testing.expectEqual(true, el instanceof Outer);
}
</script>
<script id="report_during_report_dispatch_is_not_swallowed">
{
// Regression test: reportError's event dispatch must not pump the
// microtask queue. It used to, and a report fired by a pumped
// continuation was silently dropped by the error-reporting-mode
// guard.
class ThrowsSync extends HTMLElement {
constructor() {
super();
throw new Error('sync');
}
}
class ThrowsNested extends HTMLElement {
constructor() {
super();
throw new Error('nested');
}
}
customElements.define('ce-swallow-sync', ThrowsSync);
customElements.define('ce-swallow-nested', ThrowsNested);
window.__nested_report = null;
Promise.resolve().then(() => {
window.onerror = (m, u, l, c, error) => { window.__nested_report = error; return true; };
document.createElement('ce-swallow-nested');
window.onerror = null;
});
let reported = null;
window.onerror = (m, u, l, c, error) => { reported = error; return true; };
document.createElement('ce-swallow-sync');
window.onerror = null;
testing.expectEqual('sync', reported.message);
}
</script>
<script id="report_during_report_dispatch_is_not_swallowed_check">
{
// The continuation queued by the previous block has run by now (the
// microtask checkpoint sits between the two script evaluations) and
// its report must have been delivered.
testing.expectEqual('nested', window.__nested_report.message);
}
</script>
+20
View File
@@ -62,6 +62,26 @@
}
</script>
<script id=webkitMatchesSelector>
{
// Legacy alias of matches(): same behaviour, including error handling.
const container = $('#test-container');
const span = $('#special');
testing.expectEqual(true, container.webkitMatchesSelector('#test-container'));
testing.expectEqual(true, container.webkitMatchesSelector('.container.main'));
testing.expectEqual(true, container.webkitMatchesSelector('*'));
testing.expectEqual(false, container.webkitMatchesSelector('p'));
testing.expectEqual(true, span.webkitMatchesSelector('span#special.wrapper'));
testing.expectEqual(false, span.webkitMatchesSelector('div'));
testing.expectError("SyntaxError", () => container.webkitMatchesSelector(''));
testing.expectEqual(1, Element.prototype.webkitMatchesSelector.length);
}
</script>
<script id=errorHandling>
{
const container = $('#test-container');
+70 -2
View File
@@ -53,7 +53,8 @@
box.appendChild(document.createElement('span'));
testing.expectTrue(box.scrollWidth > oneChild);
testing.expectTrue(box.scrollWidth > box.clientWidth);
// An unsized box shrink-wraps: clientWidth tracks the same content sum.
testing.expectEqual(box.scrollWidth, box.clientWidth);
// Text contributes nothing, however long. Estimating a run from its length
// would need a per-character advance that tracks font-size, and would report
@@ -133,6 +134,72 @@
}
</script>
<script id="clientWidthMarqueeLoop">
{
// The clientWidth variant of the marquee idiom (amivoice.com): clone items
// into an unsized wrap until it is twice the width of its container.
// clientWidth of the wrap has to respond to the insertion, while the
// container — whose direct child is the wrap, not the items — must not
// grow in lockstep or the loop still never terminates.
const container = document.createElement('div');
const wrap = document.createElement('div');
const item = document.createElement('span');
wrap.appendChild(item);
container.appendChild(wrap);
document.body.appendChild(container);
const containerBefore = container.clientWidth;
let guard = 0;
while (wrap.clientWidth < container.clientWidth * 2 && guard < 50) {
wrap.appendChild(item.cloneNode(true));
guard++;
}
testing.expectTrue(guard < 50);
testing.expectEqual(containerBefore, container.clientWidth);
// An explicit inline size pins the box regardless of content.
const pinned = document.createElement('div');
pinned.style.width = '40px';
pinned.appendChild(document.createElement('span'));
pinned.appendChild(document.createElement('span'));
pinned.appendChild(document.createElement('span'));
document.body.appendChild(pinned);
testing.expectEqual(40, pinned.clientWidth);
}
</script>
<script id="computedWidthMarqueeLoop">
{
// The getComputedStyle variant of the marquee idiom (inchurch.com.br's
// dynamic-marquee-widget via jQuery .width()): the computed value must
// carry the same content fallback as clientWidth or the loop never
// terminates.
const container = document.createElement('div');
const wrap = document.createElement('div');
const item = document.createElement('span');
wrap.appendChild(item);
container.appendChild(wrap);
document.body.appendChild(container);
const width = (el) => parseFloat(getComputedStyle(el).width);
const containerWidth = width(container);
let guard = 0;
while (width(wrap) < containerWidth * 2 && guard < 50) {
wrap.appendChild(item.cloneNode(true));
guard++;
}
testing.expectTrue(guard < 50);
// An explicit inline size still wins over the content sum.
const pinned = document.createElement('div');
pinned.style.width = '40px';
pinned.appendChild(document.createElement('span'));
pinned.appendChild(document.createElement('span'));
document.body.appendChild(pinned);
testing.expectEqual('40px', getComputedStyle(pinned).width);
}
</script>
<script id="scrollHeightFromContent">
{
// An empty element has no content to overflow its box.
@@ -149,7 +216,8 @@
box.appendChild(document.createElement('div'));
testing.expectTrue(box.scrollHeight > oneChild);
testing.expectTrue(box.scrollHeight > box.clientHeight);
// An unsized box shrink-wraps: clientHeight tracks the same content sum.
testing.expectEqual(box.scrollHeight, box.clientHeight);
// Text contributes no height, as it contributes no width: a text child's
// line box is what the element's own size already stands in for.
+11
View File
@@ -224,6 +224,17 @@
div.style.setProperty('text-transform', 'lowercase');
testing.expectEqual('lowercase', window.getComputedStyle(div).getPropertyValue('text-transform'));
// Pseudo-elements get their own cache entry, keyed per (element, pseudo);
// the legacy single-colon form maps to the same entry as the double-colon
// one, and a pseudoElt without a leading colon is ignored (CSSOM).
const before = window.getComputedStyle(div, ':before');
testing.expectTrue(before === window.getComputedStyle(div, '::before'));
testing.expectTrue(before === window.getComputedStyle(div, ':BEFORE'));
testing.expectFalse(before === cs);
testing.expectFalse(before === window.getComputedStyle(div, '::after'));
testing.expectTrue(cs === window.getComputedStyle(div, 'before'));
testing.expectTrue(cs === window.getComputedStyle(div, ''));
// A normal declaration must not override an earlier !important one, and the
// computed and inline (el.style) paths must agree on the resolved value.
const impDiv = document.createElement('div');
+183
View File
@@ -0,0 +1,183 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<!-- parser-inserted srcdoc iframe; its load event is queued while the parent
is still parsing, so a later script's onload check observes it -->
<iframe id=static_srcdoc srcdoc="<p>static</p>"></iframe>
<script id=static_parse>
testing.onload(() => {
const doc = document.getElementById('static_srcdoc').contentDocument;
testing.expectEqual('static', doc.querySelector('p').textContent);
});
</script>
<script id=basic type=module>
{
const state = await testing.async();
const f = document.createElement('iframe');
f.srcdoc = '<p>hello</p>';
f.onload = () => state.resolve(f);
document.body.appendChild(f);
await state.done((f) => {
// same-origin as the parent: contentDocument is readable
testing.expectEqual('hello', f.contentDocument.querySelector('p').textContent);
testing.expectEqual('about:srcdoc', f.contentWindow.location.href);
testing.expectEqual('about:srcdoc', f.contentDocument.URL);
// base URL is inherited from the parent
testing.expectEqual(document.baseURI, f.contentDocument.baseURI);
});
}
</script>
<script id=srcdoc_precedence_over_src type=module>
{
const state = await testing.async();
const f = document.createElement('iframe');
f.setAttribute('src', '/xhr');
f.setAttribute('srcdoc', '<p>doc wins</p>');
f.onload = () => state.resolve(f);
document.body.appendChild(f);
await state.done((f) => {
testing.expectEqual('doc wins', f.contentDocument.querySelector('p').textContent);
testing.expectEqual('about:srcdoc', f.contentWindow.location.href);
});
}
</script>
<script id=script_runs_and_posts type=module>
{
const state = await testing.async();
const listener = (e) => {
if (e.data && e.data.test === 'script_runs') {
window.removeEventListener('message', listener);
state.resolve(e.data);
}
};
window.addEventListener('message', listener);
const f = document.createElement('iframe');
f.srcdoc = "<script>parent.postMessage({test: 'script_runs', base: document.baseURI}, '*')<\/script>";
document.body.appendChild(f);
await state.done((data) => {
testing.expectEqual(document.baseURI, data.base);
});
}
</script>
<script id=reassign_reloads type=module>
{
const state = await testing.async();
const f = document.createElement('iframe');
f.srcdoc = '<p>first</p>';
let loads = 0;
f.onload = () => {
loads += 1;
if (loads === 1) {
testing.expectEqual('first', f.contentDocument.querySelector('p').textContent);
// setAttribute on a connected iframe re-navigates too
f.setAttribute('srcdoc', '<p>second</p>');
return;
}
state.resolve(f);
};
document.body.appendChild(f);
await state.done((f) => {
testing.expectEqual('second', f.contentDocument.querySelector('p').textContent);
});
}
</script>
<script id=remove_falls_back_to_src type=module>
{
const state = await testing.async();
const f = document.createElement('iframe');
f.srcdoc = '<p>content</p>';
let loads = 0;
f.onload = () => {
loads += 1;
if (loads === 1) {
f.removeAttribute('srcdoc');
return;
}
state.resolve(f);
};
document.body.appendChild(f);
await state.done((f) => {
// no src attribute, so removal navigates back to about:blank
testing.expectEqual('about:blank', f.contentWindow.location.href);
testing.expectEqual(null, f.contentDocument.querySelector('p'));
});
}
</script>
<script id=empty_srcdoc type=module>
{
const state = await testing.async();
const f = document.createElement('iframe');
f.srcdoc = '';
f.onload = () => state.resolve(f);
document.body.appendChild(f);
await state.done((f) => {
testing.expectEqual('about:srcdoc', f.contentWindow.location.href);
testing.expectEqual('', f.contentDocument.body.textContent);
});
}
</script>
<script id=referrer_is_parent type=module>
{
// a request from inside a srcdoc frame carries the parent's URL as the
// Referer (about:srcdoc isn't a valid referrer source); the relative
// fetch URL also proves base inheritance
const state = await testing.async();
const listener = (e) => {
if (e.data && e.data.test === 'referrer') {
window.removeEventListener('message', listener);
state.resolve(e.data);
}
};
window.addEventListener('message', listener);
const f = document.createElement('iframe');
f.srcdoc = "<script>fetch('/echo_referer').then(r => r.text()).then(t => parent.postMessage({test: 'referrer', body: t}, '*'))<\/script>";
document.body.appendChild(f);
await state.done((data) => {
testing.expectEqual(true, data.body.includes('referer=' + location.href));
});
}
</script>
<script id=meta_referrer_in_srcdoc type=module>
{
// <meta name=referrer> inside the srcdoc content sets that frame's policy
const state = await testing.async();
const listener = (e) => {
if (e.data && e.data.test === 'meta_referrer') {
window.removeEventListener('message', listener);
state.resolve(e.data);
}
};
window.addEventListener('message', listener);
const f = document.createElement('iframe');
f.srcdoc = "<meta name='referrer' content='no-referrer'><script>fetch('/echo_referer').then(r => r.text()).then(t => parent.postMessage({test: 'meta_referrer', body: t}, '*'))<\/script>";
document.body.appendChild(f);
await state.done((data) => {
testing.expectEqual(true, data.body.includes('referer=NONE'));
});
}
</script>
+41
View File
@@ -126,3 +126,44 @@
});
}
</script>
<!-- SVG <a> has the same link activation behavior as HTML <a>. SVGElement has
no click(), so activate via a dispatched MouseEvent, bubbling up from the
<text> child like a real click on the rendered link. -->
<iframe name=frame7 id=f7></iframe>
<svg xmlns="http://www.w3.org/2000/svg">
<a href="support/page.html" target=frame7><text id=svgtext y=10>svg link</text></a>
</svg>
<script id=svg_anchor type=module>
{
const state = await testing.async();
$('#svgtext').dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
$('#f7').onload = () => {
state.resolve();
};
await state.done(() => {
testing.expectEqual('a-page\n', $('#f7').contentDocument.body.textContent);
});
}
</script>
<iframe name=frame8 id=f8></iframe>
<svg xmlns="http://www.w3.org/2000/svg">
<a id=svglink2 xlink:href="support/page.html" target=frame8><text y=10>svg 1.1 link</text></a>
</svg>
<script id=svg_anchor_xlink type=module>
{
const state = await testing.async();
$('#svglink2').dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
$('#f8').onload = () => {
state.resolve();
};
await state.done(() => {
testing.expectEqual('a-page\n', $('#f8').contentDocument.body.textContent);
});
}
</script>
+17
View File
@@ -451,3 +451,20 @@
});
}
</script>
<!-- Referer across redirects: a same-origin hop keeps the full referrer; a
cross-origin hop (localhost alias) recomputes it at the redirect, which
the default policy (strict-origin-when-cross-origin) strips to origin. -->
<script id=fetch_redirect_referer type=module>
{
const state = await testing.async();
const same = await (await fetch('/redirect_same_echo_referer')).text();
const cross = await (await fetch('/redirect_cross_echo_referer')).text();
state.resolve();
await state.done(() => {
testing.expectEqual(`<html><body>referer=${location.href}</body></html>`, same);
testing.expectEqual('<html><body>referer=http://127.0.0.1:9582/</body></html>', cross);
});
}
</script>
+138
View File
@@ -326,3 +326,141 @@
host.remove();
}
</script>
<script id="related_target_stops_path">
{
// DOM dispatch: the path stops at the node the relatedTarget retargets
// onto, so the event never reaches the host or anything above it.
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const target = document.createElement('span');
shadow.appendChild(target);
const seen = [];
const watch = (node, name) => node.addEventListener('my-event', () => seen.push(name));
watch(target, 'target');
watch(shadow, 'shadow');
watch(host, 'host');
watch(document.body, 'body');
watch(document, 'document');
target.dispatchEvent(new MouseEvent('my-event', {
bubbles: true,
composed: true,
relatedTarget: host,
}));
testing.expectEqual('target,shadow', seen.join(','));
host.remove();
}
</script>
<script id="related_target_equal_to_target_skips_dispatch">
{
// When the relatedTarget retargets onto the target itself, the event is
// not dispatched at all.
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('span');
shadow.appendChild(inner);
let hostCalled = false;
host.addEventListener('my-event', () => hostCalled = true);
// relatedTarget lives in host's own shadow tree, so it retargets to host.
host.dispatchEvent(new MouseEvent('my-event', {
bubbles: true,
composed: true,
relatedTarget: inner,
}));
testing.expectEqual(false, hostCalled);
host.remove();
}
</script>
<script id="related_target_retargeted_per_listener">
{
// Each listener sees the relatedTarget retargeted against its own
// currentTarget: the slot is inside the shadow tree and sees the real
// node, everything in the document tree sees the host.
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
const related = document.createElement('i');
shadow.appendChild(slot);
shadow.appendChild(related);
const target = document.createElement('span');
host.appendChild(target);
const seen = [];
const watch = (node, name) => node.addEventListener('my-event', (e) => {
seen.push(name + '=' + (e.relatedTarget === related ? 'related' : (e.relatedTarget === host ? 'host' : '?')));
});
watch(target, 'target');
watch(slot, 'slot');
watch(host, 'host');
target.dispatchEvent(new MouseEvent('my-event', {
bubbles: true,
composed: true,
relatedTarget: related,
}));
testing.expectEqual('target=host,slot=related,host=host', seen.join(','));
host.remove();
}
</script>
<script id="related_target_post_dispatch">
{
// Post-dispatch the relatedTarget is left retargeted against the outermost
// node the event reached, or cleared when it would expose a shadow tree.
const makeHost = (tag) => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement(tag);
shadow.appendChild(inner);
return { host: host, inner: inner };
};
// target and relatedTarget in different shadow trees: each is exposed as
// its own host.
const a = makeHost('span');
const b = makeHost('i');
const across = new MouseEvent('my-event', {
bubbles: true,
composed: true,
relatedTarget: b.inner,
});
a.inner.dispatchEvent(across);
testing.expectEqual(a.host, across.target);
testing.expectEqual(b.host, across.relatedTarget);
// target and relatedTarget in the same shadow tree: the event never leaves
// it, so both are cleared rather than leaking a shadow node.
const c = makeHost('span');
const sibling = document.createElement('i');
c.inner.parentNode.appendChild(sibling);
const within = new MouseEvent('my-event', {
bubbles: true,
composed: true,
relatedTarget: sibling,
});
c.inner.dispatchEvent(within);
testing.expectEqual(null, within.target);
testing.expectEqual(null, within.relatedTarget);
a.host.remove();
b.host.remove();
c.host.remove();
}
</script>
+119 -21
View File
@@ -276,6 +276,16 @@ pub const Tool = enum {
};
}
/// Waits for page readiness on its own, letting the recorder downgrade a
/// preceding `goto` to `domcontentloaded`. Exhaustive like the sibling
/// predicates so a new wait tool makes an explicit choice here.
pub fn waitsForReadiness(self: Tool) bool {
return switch (self) {
.waitForSelector, .waitForScript, .waitForState => true,
.goto, .evaluate, .extract, .click, .fill, .scroll, .hover, .press, .selectOption, .setChecked, .search, .markdown, .html, .links, .tree, .nodeDetails, .interactiveElements, .structuredData, .detectForms, .findElement, .consoleLogs, .getUrl, .getCookies, .getEnv => false,
};
}
/// A read tool that navigates when handed a `url`. The read isn't recorded,
/// but the navigation is, so the recorder captures it as a `goto`. Excludes
/// `evaluate` (carries its own `url`), `search` (derived engine URL), and
@@ -329,7 +339,10 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "url": { "type": "string", "description": "The URL to navigate to, must be a valid URL." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "waitUntil": { "type": "string", "enum":
++ lp.Config.tagJsonArray(lp.Config.WaitUntil) ++
\\, "description": "Event that completes the navigation. Defaults to 'load'. Prefer 'domcontentloaded' followed by waitForSelector on pages whose late scripts (ads) hold 'load' back. Avoid 'done' (full quiescence): on pages with constant background activity it is the slowest choice and can run to the timeout." }
\\ },
\\ "required": ["url"]
\\}
@@ -357,7 +370,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "Optional CSS selector. Render markdown for just that element's subtree." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. Render markdown for just that node's subtree. 0 is treated as omitted." },
\\ "maxBytes": { "type": "integer", "description": "Optional soft cap on output size in bytes. Content is truncated at a UTF-8 boundary and a short '[truncated]' marker is appended past the cap." },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before rendering." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
@@ -373,7 +386,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "Optional CSS selector. When set, dump only that element's outerHTML." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID. When set, dump only that node's outerHTML. 0 is treated as omitted." },
\\ "url": { "type": "string", "description": "Optional URL to navigate to before dumping." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." }
\\ }
@@ -441,7 +454,7 @@ pub const Tool = enum {
\\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 10000." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root. 0 is treated as omitted." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ }
\\}
@@ -510,7 +523,7 @@ pub const Tool = enum {
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." },
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted (or 0), scrolls the window." },
\\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
\\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
\\ }
@@ -525,7 +538,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "selector": { "type": "string", "description": "The CSS selector to wait for." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." }
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." }
\\ },
\\ "required": ["selector"]
\\}
@@ -539,7 +552,7 @@ pub const Tool = enum {
\\ "type": "object",
\\ "properties": {
\\ "script": { "type": "string", "description": "JS expression evaluated each tick until truthy. Must be an expression (not a statement)." },
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000." }
\\ "timeout": { "type": "integer", "description": "Optional timeout in milliseconds. Defaults to 5000, or 15000 when the page has not reached 'load' yet." }
\\ },
\\ "required": ["script"]
\\}
@@ -583,7 +596,7 @@ pub const Tool = enum {
\\ "properties": {
\\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
\\ "selector": { "type": "string", "description": "Optional CSS selector of the element to target. Preferred over backendNodeId." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided." }
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document when neither selector nor backendNodeId is provided; 0 is treated as omitted." }
\\ },
\\ "required": ["key"]
\\}
@@ -744,6 +757,16 @@ pub const ToolError = error{
OutOfMemory,
};
/// LLM-facing message for a tool failure. Bare error names leave the model
/// retrying blind; spell out the recovery for errors it can act on.
pub fn errorMessage(err: ToolError) []const u8 {
return switch (err) {
error.NodeNotFound => "NodeNotFound: the selector or backendNodeId matched nothing on the current page. Re-inspect the page (tree/interactiveElements) for fresh node ids, or omit backendNodeId to target the document root.",
error.FrameNotLoaded => "FrameNotLoaded: no page is loaded — call goto (or pass a url) first.",
else => @errorName(err),
};
}
/// Outcome of running a tool against the page. Operational failures (OOM,
/// missing page, invalid params) come out as Zig errors on the enclosing
/// `!ToolResult`; `is_error = true` is the in-band signal for a JS-level
@@ -758,6 +781,7 @@ pub const ToolResult = struct {
pub const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: lp.Config.WaitUntil = default_nav_wait,
};
pub const UrlParams = struct {
@@ -786,7 +810,13 @@ pub fn call(
tool_name: []const u8,
arguments: ?std.json.Value,
) ToolError!ToolResult {
const tool = std.meta.stringToEnum(Tool, tool_name) orelse return ToolError.InvalidParams;
// In-band so an LLM that invented a tool name (e.g. OpenAI's internal
// `multi_tool_use.parallel` wrapper) learns the name is wrong instead of
// retrying it with different arguments.
const tool = std.meta.stringToEnum(Tool, tool_name) orelse return .{
.text = try std.fmt.allocPrint(arena, "Unknown tool: {s}", .{tool_name}),
.is_error = true,
};
if (diagnoseArgs(arena, arguments)) |msg|
return .{ .text = msg, .is_error = true };
// Must run before substituteStringArgs so the `key=="value"` secret-
@@ -936,7 +966,7 @@ const schema_walker_suffix = ")";
fn execGoto(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
const args = try parseArgs(GotoParams, arena, arguments);
return switch (try performGoto(session, registry, args.url, args.timeout)) {
return switch (try performGoto(session, registry, args.url, .{ .timeout = args.timeout, .wait_until = args.waitUntil })) {
.completed => "Navigated successfully.",
.timeout => "Navigation started but the page did not finish loading before the timeout.",
};
@@ -988,7 +1018,7 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
.{encoded},
0,
) catch return ToolError.OutOfMemory;
_ = try performGoto(session, registry, ddg_url, args.timeout);
_ = try performGoto(session, registry, ddg_url, .{ .timeout = args.timeout });
const ddg_frame = try requireFrame(session);
return .{ .text = try renderFrameMarkdown(arena, ddg_frame) };
}
@@ -1619,6 +1649,14 @@ fn execScroll(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
/// already-loaded page rather than a full navigation (which uses 10000).
const default_wait_timeout_ms: u32 = 5000;
/// A wait entered before `load` also inherits the nav budget: a post-load
/// selector keeps the wall time it had when `goto` waited for `load` itself.
/// Shared with CDP's `LP.waitForSelector`, which fronts the same action.
pub fn defaultWaitTimeout(frame: *const lp.Frame) u32 {
if (frame._load_state == .complete) return default_wait_timeout_ms;
return default_nav_timeout_ms + default_wait_timeout_ms;
}
fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
const Params = struct {
selector: [:0]const u8,
@@ -1628,7 +1666,7 @@ fn execWaitForSelector(arena: std.mem.Allocator, session: *lp.Session, registry:
const frame = try requireFrame(session);
const timeout_ms = args.timeout orelse default_wait_timeout_ms;
const timeout_ms = args.timeout orelse defaultWaitTimeout(frame);
const node = lp.actions.waitForSelector(args.selector, timeout_ms, frame._frame_id, session) catch |err| switch (err) {
error.InvalidSelector => return ToolError.InvalidParams,
@@ -1655,7 +1693,7 @@ fn execWaitForScript(arena: std.mem.Allocator, session: *lp.Session, arguments:
const frame = try requireFrame(session);
const timeout_ms = args.timeout orelse default_wait_timeout_ms;
const timeout_ms = args.timeout orelse defaultWaitTimeout(frame);
lp.actions.waitForScript(args.script, timeout_ms, frame._frame_id, session) catch |err| switch (err) {
error.Cancelled => return ToolError.Cancelled,
@@ -1921,7 +1959,7 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const
if (session.currentFrame()) |frame| {
if (std.mem.eql(u8, frame.url, u)) return frame;
}
_ = try performGoto(session, registry, u, timeout);
_ = try performGoto(session, registry, u, .{ .timeout = timeout });
}
return session.currentFrame() orelse ToolError.FrameNotLoaded;
}
@@ -1937,6 +1975,7 @@ const default_nav_timeout_ms: u32 = 10000;
pub const StartedGoto = struct {
frame_id: u32,
timeout_ms: u32,
until: lp.Config.WaitUntil,
};
/// Open a fresh top-level page and start its navigation. The frame is non-null
@@ -1971,10 +2010,19 @@ pub fn startGoto(
}
}
const page = try openPage(session, args.url);
return .{ .frame_id = page.frame_id, .timeout_ms = args.timeout orelse default_nav_timeout_ms };
return .{
.frame_id = page.frame_id,
.timeout_ms = args.timeout orelse default_nav_timeout_ms,
.until = args.waitUntil,
};
}
fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32) ToolError!lp.Session.Runner.WaitResult {
const PerformGotoOpts = struct {
timeout: ?u32 = null,
wait_until: lp.Config.WaitUntil = default_nav_wait,
};
fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, opts: PerformGotoOpts) ToolError!lp.Session.Runner.WaitResult {
if (session.primaryPage()) |old_page| {
registry.reset();
old_page.close();
@@ -1982,9 +2030,9 @@ fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const
const page = try openPage(session, url);
var runner = session.runner(.{});
const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = default_nav_wait };
const condition = lp.Session.Runner.WaitCondition{ .frame_id = page.frame_id, .until = opts.wait_until };
var conditions = [_]lp.Session.Runner.WaitCondition{condition};
const result = runner.waitResult(timeout orelse default_nav_timeout_ms, &conditions) catch |err| {
const result = runner.waitResult(opts.timeout orelse default_nav_timeout_ms, &conditions) catch |err| {
return if (err == error.Cancelled) ToolError.Cancelled else ToolError.NavigationFailed;
};
@@ -2038,13 +2086,23 @@ fn formatEnumError(arena: std.mem.Allocator, field: []const u8, got: []const u8,
}
pub fn parseValue(comptime T: type, arena: std.mem.Allocator, value: std.json.Value) ParseArgsError!T {
return std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
var parsed = std.json.parseFromValueLeaky(T, arena, value, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => {
log.debug(.browser, "parseValue rejected", .{ .err = @errorName(err), .type = @typeName(T) });
return error.InvalidParams;
},
};
// Schema contract: backendNodeId 0 means omitted — registry ids start at 1,
// and zero-filling models (gpt-5.x) send 0 for "unset".
if (comptime @typeInfo(T) == .@"struct" and @hasField(T, "backendNodeId") and
@typeInfo(@FieldType(T, "backendNodeId")) == .optional)
{
if (parsed.backendNodeId) |nid| {
if (nid == 0) parsed.backendNodeId = null;
}
}
return parsed;
}
/// For tools where every field is optional. Missing args → default `T`;
@@ -2170,7 +2228,7 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err
// before its full match is found, leaking a suffix into the recording.
const Pair = struct { name: []const u8, value: []const u8 };
var pairs: std.ArrayList(Pair) = .empty;
try pairs.ensureTotalCapacity(arena, env_names.len);
try pairs.ensureTotalCapacityPrecise(arena, env_names.len);
for (env_names) |name| {
const value = lookupLpEnv(name) orelse continue;
if (value.len < 4) continue;
@@ -2193,6 +2251,46 @@ pub fn reverseSubstituteEnvVars(arena: std.mem.Allocator, input: []const u8) err
return if (changed) current else input;
}
test "call: unknown tool name surfaces in-band" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
// Session/registry are never touched on this branch; the name check is
// the first thing `call` does.
const r = try call(arena.allocator(), undefined, undefined, "multi_tool_use.parallel", null);
try std.testing.expect(r.is_error);
try std.testing.expectEqualStrings("Unknown tool: multi_tool_use.parallel", r.text);
}
test "parseValue: zero-filled optional backendNodeId treated as omitted" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const aa = arena.allocator();
const Params = struct {
backendNodeId: ?CDPNode.Id = null,
maxDepth: ?u32 = null,
};
const zeroed = try std.json.parseFromSliceLeaky(std.json.Value, aa,
\\{"backendNodeId":0,"maxDepth":2}
, .{});
const args = try parseValue(Params, aa, zeroed);
try std.testing.expectEqual(@as(?CDPNode.Id, null), args.backendNodeId);
try std.testing.expectEqual(@as(?u32, 2), args.maxDepth);
const real = try std.json.parseFromSliceLeaky(std.json.Value, aa,
\\{"backendNodeId":7}
, .{});
try std.testing.expectEqual(@as(?CDPNode.Id, 7), (try parseValue(Params, aa, real)).backendNodeId);
// Non-optional ids (nodeDetails) pass through untouched.
const Required = struct { backendNodeId: CDPNode.Id };
const zero_required = try std.json.parseFromSliceLeaky(std.json.Value, aa,
\\{"backendNodeId":0}
, .{});
try std.testing.expectEqual(@as(CDPNode.Id, 0), (try parseValue(Required, aa, zero_required)).backendNodeId);
}
test "substituteEnvVars resolves LP_* vars" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
+2 -2
View File
@@ -77,9 +77,9 @@ pub fn subtype(self: *const CData, comptime T: type) *T {
// the arithmetic rides on factory-chain contiguity; the stored
// back-pointer doubles as its canary
if (comptime T == CDATASection) {
std.debug.assert(sub._proto._proto == self);
std.debug.assert(sub._proto_canary._proto_canary == self);
} else {
std.debug.assert(sub._proto == self);
std.debug.assert(sub._proto_canary == self);
}
}
return sub;
+43 -5
View File
@@ -181,7 +181,9 @@ fn upgradeElement(self: *CustomElementRegistry, element: *Element, frame: *Frame
return Custom.checkAndAttachBuiltIn(element, frame);
};
if (custom._definition != null) return;
if (custom._definition != null or custom._upgrade_failed) {
return;
}
const name = custom._tag_name.str();
const definition = self._definitions.get(name) orelse return;
@@ -198,19 +200,49 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
const node = custom.asNode();
const prev_upgrading = frame._upgrading_element;
const prev_consumed = frame._upgrading_consumed;
frame._upgrading_element = node;
defer frame._upgrading_element = prev_upgrading;
frame._upgrading_consumed = false;
defer {
frame._upgrading_element = prev_upgrading;
frame._upgrading_consumed = prev_consumed;
}
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
var caught: js.TryCatch.Caught = undefined;
_ = ls.toLocal(definition.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err, .caught = caught });
const local = &ls.local;
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
const object = ls.toLocal(definition.constructor).newInstanceThrow() catch |err| {
if (err == error.ExecutionTerminated) {
custom._definition = null;
return err;
}
log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err });
upgradeFailed(custom);
if (try_catch.exceptionValue()) |exc| {
frame.window.reportError(exc, frame) catch {};
}
return error.CustomElementUpgradeFailed;
};
const same = if (object.toZig(*Node)) |result| result == node else |_| false;
if (!same) {
// the construction result must be the element being upgraded.
log.warn(.js, "custom element upgrade", .{ .name = definition.name, .reason = "constructor returned another value" });
upgradeFailed(custom);
const exc: js.Value = .{
.local = local,
.handle = local.isolate.createTypeError("custom element constructor must return the upgraded element"),
};
frame.window.reportError(exc, frame) catch {};
return error.CustomElementUpgradeFailed;
}
// Enqueue attributeChangedCallback for existing observed attributes
const element = custom.asElement();
for (element.attributeEntries()) |*attr| {
@@ -227,6 +259,11 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio
}
}
fn upgradeFailed(custom: *Custom) void {
custom._definition = null;
custom._upgrade_failed = true;
}
fn validateName(name: []const u8) !void {
if (name.len == 0) {
return error.SyntaxError;
@@ -288,5 +325,6 @@ pub const JsApi = struct {
const testing = @import("../../testing.zig");
test "WebApi: CustomElementRegistry" {
testing.expectLog(&.{ .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js, .js });
try testing.htmlRunner("custom_elements", .{});
}
+1 -1
View File
@@ -81,7 +81,7 @@ pub fn getAsString(self: *const DataTransferItem, cb_: ?js.Function) !void {
.string => |str| str,
.file => return,
};
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{s}, &caught) catch {
log.debug(.js, "getAsString callback", .{ .caught = caught, .source = "DataTransferItem" });
};
+21 -8
View File
@@ -212,6 +212,11 @@ pub fn getLastModified(self: *const Document, frame: *Frame) ![]const u8 {
});
}
pub fn getReferrer(self: *const Document) []const u8 {
const frame = self._frame orelse return "";
return frame._referrer orelse "";
}
pub fn getCharset(self: *const Document) []const u8 {
if (self._charset) |charset| {
return charset;
@@ -723,7 +728,15 @@ pub fn getReadyState(self: *const Document) []const u8 {
pub fn getActiveElement(self: *Document) ?*Element {
if (self._active_element) |el| {
return el;
// A focused element inside a shadow tree is exposed as its outermost
// host; one in a detached tree isn't exposed at all.
var candidate = el;
while (candidate.asNode().containingShadowRoot()) |shadow| {
candidate = shadow._host;
}
if (candidate.asNode().getRootNode(.{}) == self.asNode()) {
return candidate;
}
}
// Default to body if it exists
@@ -760,6 +773,9 @@ pub fn adoptNode(self: *Document, node: *Node, frame: *Frame) !*Node {
if (node._type == .document) {
return error.NotSupported;
}
if (node.is(Node.ShadowRoot) != null) {
return error.HierarchyError;
}
const old_owner = node.ownerDocument(frame) orelse frame.document;
@@ -1588,15 +1604,12 @@ pub const JsApi = struct {
pub const hasFocus = bridge.function(Document.hasFocus, .{});
pub const prerendering = bridge.property(false, .{ .template = false });
pub const characterSet = bridge.accessor(getCharacterSet, null, .{});
pub const charset = bridge.accessor(getCharacterSet, null, .{});
pub const inputEncoding = bridge.accessor(getCharacterSet, null, .{});
pub const characterSet = bridge.accessor(Document.getCharset, null, .{});
pub const charset = bridge.accessor(Document.getCharset, null, .{});
pub const inputEncoding = bridge.accessor(Document.getCharset, null, .{});
pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
pub const lastModified = bridge.accessor(Document.getLastModified, null, .{});
fn getCharacterSet(self: *const Document) []const u8 {
return self.getCharset();
}
pub const referrer = bridge.property("", .{ .template = false });
pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
// Generates a getter/setter pair backed by the frame's attribute-listener
// map, like onclick above, for other document event handler properties.
+361 -260
View File
@@ -51,6 +51,30 @@ pub const Proto = Node;
pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap);
pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
pub const ComputedStyleLookup = std.AutoHashMapUnmanaged(ComputedStyleKey, *CSSStyleProperties);
pub const ComputedStyleKey = struct {
element: *Element,
pseudo: PseudoElement,
};
pub const PseudoElement = enum {
none,
before,
after,
other,
pub fn parse(pseudo: []const u8) PseudoElement {
if (pseudo.len == 0 or pseudo[0] != ':') {
return .none;
}
const name = if (std.mem.startsWith(u8, pseudo, "::")) pseudo[2..] else pseudo[1..];
if (std.ascii.eqlIgnoreCase(name, "before")) return .before;
if (std.ascii.eqlIgnoreCase(name, "after")) return .after;
return .other;
}
};
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
@@ -115,15 +139,35 @@ _attributes: Attribute.List = .{},
// work to resolve the proto).
_proto_canary: if (lp.IS_DEBUG) *Node else void = undefined,
pub const Type = union(enum) {
html: *Html,
svg: *Svg,
pub const Type = enum(u8) {
html,
svg,
};
pub fn Subtype(comptime tag: Type) type {
return switch (tag) {
.html => Html,
.svg => Svg,
};
}
pub fn subtype(self: *const Element, comptime T: type) *T {
const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Element);
const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
if (comptime lp.IS_DEBUG) {
// This pointer dance only works because the factory allocates the chain
// in a contiguous block of memory. In debug, we assert this holds via
// the _proto_canary back pointer.
std.debug.assert(Factory.protoOf(sub) == self);
}
return sub;
}
pub fn is(self: *Element, comptime T: type) ?*T {
const type_name = @typeName(T);
switch (self._type) {
.html => |el| {
.html => {
const el = self.subtype(Html);
if (T == Html) {
return el;
}
@@ -131,7 +175,8 @@ pub fn is(self: *Element, comptime T: type) ?*T {
return el.is(T);
}
},
.svg => |svg| {
.svg => {
const svg = self.subtype(Svg);
if (T == Svg) {
return svg;
}
@@ -178,177 +223,183 @@ pub fn isEqualNode(self: *Element, other: *Element) bool {
pub fn getTagNameLower(self: *const Element) []const u8 {
switch (self._type) {
.html => |he| switch (he._type) {
.custom => |ce| {
@branchHint(.unlikely);
return ce._tag_name.str();
},
else => return switch (he._type) {
.anchor => "a",
.area => "area",
.base => "base",
.body => "body",
.br => "br",
.button => "button",
.canvas => "canvas",
.custom => |e| e._tag_name.str(),
.data => "data",
.datalist => "datalist",
.details => "details",
.dialog => "dialog",
.directory => "dir",
.div => "div",
.dl => "dl",
.embed => "embed",
.fieldset => "fieldset",
.font => "font",
.frameset => "frameset",
.form => "form",
.generic => |e| e._tag_name.str(),
.heading => |e| e._tag_name.str(),
.head => "head",
.html => "html",
.hr => "hr",
.iframe => "iframe",
.img => "img",
.input => "input",
.label => "label",
.legend => "legend",
.li => "li",
.link => "link",
.map => "map",
.marquee => "marquee",
.media => |m| switch (m._type) {
.audio => "audio",
.video => "video",
.generic => "media",
.html => {
const he = self.subtype(Html);
switch (he._type) {
.custom => {
@branchHint(.unlikely);
return he.subtype(Html.Custom)._tag_name.str();
},
.meta => "meta",
.meter => "meter",
.mod => |e| e._tag_name.str(),
.object => "object",
.ol => "ol",
.optgroup => "optgroup",
.option => "option",
.output => "output",
.p => "p",
.picture => "picture",
.param => "param",
.pre => "pre",
.progress => "progress",
.quote => |e| e._tag_name.str(),
.script => "script",
.select => "select",
.slot => "slot",
.source => "source",
.span => "span",
.style => "style",
.table => "table",
.table_caption => "caption",
.table_cell => |e| e._tag_name.str(),
.table_col => |e| e._tag_name.str(),
.table_row => "tr",
.table_section => |e| e._tag_name.str(),
.template => "template",
.textarea => "textarea",
.time => "time",
.title => "title",
.track => "track",
.ul => "ul",
.unknown => |e| e._tag_name.str(),
},
else => return switch (he._type) {
.anchor => "a",
.area => "area",
.base => "base",
.body => "body",
.br => "br",
.button => "button",
.canvas => "canvas",
.custom => he.subtype(Html.Custom)._tag_name.str(),
.data => "data",
.datalist => "datalist",
.details => "details",
.dialog => "dialog",
.directory => "dir",
.div => "div",
.dl => "dl",
.embed => "embed",
.fieldset => "fieldset",
.font => "font",
.frameset => "frameset",
.form => "form",
.generic => he.subtype(Html.Generic)._tag_name.str(),
.heading => he.subtype(Html.Heading)._tag_name.str(),
.head => "head",
.html => "html",
.hr => "hr",
.iframe => "iframe",
.img => "img",
.input => "input",
.label => "label",
.legend => "legend",
.li => "li",
.link => "link",
.map => "map",
.marquee => "marquee",
.media => switch (he.subtype(Html.Media)._type) {
.audio => "audio",
.video => "video",
.generic => "media",
},
.meta => "meta",
.meter => "meter",
.mod => he.subtype(Html.Mod)._tag_name.str(),
.object => "object",
.ol => "ol",
.optgroup => "optgroup",
.option => "option",
.output => "output",
.p => "p",
.picture => "picture",
.param => "param",
.pre => "pre",
.progress => "progress",
.quote => he.subtype(Html.Quote)._tag_name.str(),
.script => "script",
.select => "select",
.slot => "slot",
.source => "source",
.span => "span",
.style => "style",
.table => "table",
.table_caption => "caption",
.table_cell => he.subtype(Html.TableCell)._tag_name.str(),
.table_col => he.subtype(Html.TableCol)._tag_name.str(),
.table_row => "tr",
.table_section => he.subtype(Html.TableSection)._tag_name.str(),
.template => "template",
.textarea => "textarea",
.time => "time",
.title => "title",
.track => "track",
.ul => "ul",
.unknown => he.subtype(Html.Unknown)._tag_name.str(),
},
}
},
.svg => |svg| return svg._tag_name.str(),
.svg => return self.subtype(Svg)._tag_name.str(),
}
}
pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
return switch (self._type) {
.html => |he| switch (he._type) {
.anchor => "A",
.area => "AREA",
.base => "BASE",
.body => "BODY",
.br => "BR",
.button => "BUTTON",
.canvas => "CANVAS",
.custom => |e| upperTagName(&e._tag_name, buf),
.data => "DATA",
.datalist => "DATALIST",
.details => "DETAILS",
.dialog => "DIALOG",
.directory => "DIR",
.div => "DIV",
.dl => "DL",
.embed => "EMBED",
.fieldset => "FIELDSET",
.font => "FONT",
.frameset => "FRAMESET",
.form => "FORM",
.generic => |e| upperTagName(&e._tag_name, buf),
.heading => |e| upperTagName(&e._tag_name, buf),
.head => "HEAD",
.html => "HTML",
.hr => "HR",
.iframe => "IFRAME",
.img => "IMG",
.input => "INPUT",
.label => "LABEL",
.legend => "LEGEND",
.li => "LI",
.link => "LINK",
.map => "MAP",
.marquee => "MARQUEE",
.meta => "META",
.media => |m| switch (m._type) {
.audio => "AUDIO",
.video => "VIDEO",
.generic => "MEDIA",
},
.meter => "METER",
.mod => |e| upperTagName(&e._tag_name, buf),
.object => "OBJECT",
.ol => "OL",
.optgroup => "OPTGROUP",
.option => "OPTION",
.output => "OUTPUT",
.p => "P",
.picture => "PICTURE",
.param => "PARAM",
.pre => "PRE",
.progress => "PROGRESS",
.quote => |e| upperTagName(&e._tag_name, buf),
.script => "SCRIPT",
.select => "SELECT",
.slot => "SLOT",
.source => "SOURCE",
.span => "SPAN",
.style => "STYLE",
.table => "TABLE",
.table_caption => "CAPTION",
.table_cell => |e| upperTagName(&e._tag_name, buf),
.table_col => |e| upperTagName(&e._tag_name, buf),
.table_row => "TR",
.table_section => |e| upperTagName(&e._tag_name, buf),
.template => "TEMPLATE",
.textarea => "TEXTAREA",
.time => "TIME",
.title => "TITLE",
.track => "TRACK",
.ul => "UL",
.unknown => |e| switch (self._namespace) {
.html => upperTagName(&e._tag_name, buf),
.svg, .xml, .mathml, .unknown, .null => e._tag_name.str(),
},
.html => blk: {
const he = self.subtype(Html);
break :blk switch (he._type) {
.anchor => "A",
.area => "AREA",
.base => "BASE",
.body => "BODY",
.br => "BR",
.button => "BUTTON",
.canvas => "CANVAS",
.custom => upperTagName(&he.subtype(Html.Custom)._tag_name, buf),
.data => "DATA",
.datalist => "DATALIST",
.details => "DETAILS",
.dialog => "DIALOG",
.directory => "DIR",
.div => "DIV",
.dl => "DL",
.embed => "EMBED",
.fieldset => "FIELDSET",
.font => "FONT",
.frameset => "FRAMESET",
.form => "FORM",
.generic => upperTagName(&he.subtype(Html.Generic)._tag_name, buf),
.heading => upperTagName(&he.subtype(Html.Heading)._tag_name, buf),
.head => "HEAD",
.html => "HTML",
.hr => "HR",
.iframe => "IFRAME",
.img => "IMG",
.input => "INPUT",
.label => "LABEL",
.legend => "LEGEND",
.li => "LI",
.link => "LINK",
.map => "MAP",
.marquee => "MARQUEE",
.meta => "META",
.media => switch (he.subtype(Html.Media)._type) {
.audio => "AUDIO",
.video => "VIDEO",
.generic => "MEDIA",
},
.meter => "METER",
.mod => upperTagName(&he.subtype(Html.Mod)._tag_name, buf),
.object => "OBJECT",
.ol => "OL",
.optgroup => "OPTGROUP",
.option => "OPTION",
.output => "OUTPUT",
.p => "P",
.picture => "PICTURE",
.param => "PARAM",
.pre => "PRE",
.progress => "PROGRESS",
.quote => upperTagName(&he.subtype(Html.Quote)._tag_name, buf),
.script => "SCRIPT",
.select => "SELECT",
.slot => "SLOT",
.source => "SOURCE",
.span => "SPAN",
.style => "STYLE",
.table => "TABLE",
.table_caption => "CAPTION",
.table_cell => upperTagName(&he.subtype(Html.TableCell)._tag_name, buf),
.table_col => upperTagName(&he.subtype(Html.TableCol)._tag_name, buf),
.table_row => "TR",
.table_section => upperTagName(&he.subtype(Html.TableSection)._tag_name, buf),
.template => "TEMPLATE",
.textarea => "TEXTAREA",
.time => "TIME",
.title => "TITLE",
.track => "TRACK",
.ul => "UL",
.unknown => switch (self._namespace) {
.html => upperTagName(&he.subtype(Html.Unknown)._tag_name, buf),
.svg, .xml, .mathml, .unknown, .null => he.subtype(Html.Unknown)._tag_name.str(),
},
};
},
.svg => |svg| svg._tag_name.str(),
.svg => self.subtype(Svg)._tag_name.str(),
};
}
pub fn getTagNameDump(self: *const Element) []const u8 {
switch (self._type) {
.html => return self.getTagNameLower(),
.svg => |svg| return svg._tag_name.str(),
.svg => return self.subtype(Svg)._tag_name.str(),
}
}
@@ -1255,17 +1306,30 @@ pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, frame: *Fram
});
}
pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64, height: f64 } {
var width: f64 = 5.0;
var height: f64 = 5.0;
pub const Dimensions = struct {
width: f64,
height: f64,
// if the value is explicit (e.g. inline style, width attribute, ...) or defaulted
explicit_width: bool = false,
explicit_height: bool = false,
};
pub fn getElementDimensions(self: *Element, frame: *Frame) Dimensions {
var dims: Dimensions = .{ .width = 5.0, .height = 5.0 };
if (self.getStyle(frame)) |style| {
const decl = style.asCSSStyleDeclaration();
width = CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame) orelse 5.0;
height = CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame) orelse 5.0;
if (CSS.parseDimensionViewport(decl.getPropertyValue("width", frame), frame)) |w| {
dims.width = w;
dims.explicit_width = true;
}
if (CSS.parseDimensionViewport(decl.getPropertyValue("height", frame), frame)) |h| {
dims.height = h;
dims.explicit_height = true;
}
}
if (width == 5.0 or height == 5.0) {
if (dims.width == 5.0 or dims.height == 5.0) {
const tag = self.getTag();
// Root containers get large default size to contain descendant positions.
@@ -1273,35 +1337,69 @@ pub fn getElementDimensions(self: *Element, frame: *Frame) struct { width: f64,
// even very deep trees (100 levels) stay within 10,000px.
// 100M pixels is plausible for very long documents.
if (tag == .html or tag == .body) {
if (width == 5.0) width = 1920.0;
if (height == 5.0) height = 100_000_000.0;
if (dims.width == 5.0) dims.width = 1920.0;
if (dims.height == 5.0) dims.height = 100_000_000.0;
} else if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe(comptime .wrap("width"))) |w| {
width = std.fmt.parseFloat(f64, w) catch width;
if (std.fmt.parseFloat(f64, w)) |parsed| {
dims.width = parsed;
dims.explicit_width = true;
} else |_| {}
}
if (self.getAttributeSafe(comptime .wrap("height"))) |h| {
height = std.fmt.parseFloat(f64, h) catch height;
if (std.fmt.parseFloat(f64, h)) |parsed| {
dims.height = parsed;
dims.explicit_height = true;
} else |_| {}
}
}
}
return .{ .width = width, .height = height };
return dims;
}
// We can't do this correctly without full styles and more rendering. We also
// can't just ignore the children since some sites append nodes until a certain
// width / height treshold is reached. If the size isn't explicit, we fallback
// to contentWidth/contentHeight
pub fn getClientWidth(self: *Element, frame: *Frame) f64 {
if (!self.checkVisibilityCached(null, frame)) {
var visibility_cache: VisibilityCache = .{};
return self.getClientWidthWithCache(frame, &visibility_cache);
}
pub fn getClientWidthWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
if (!self.checkVisibilityCached(visibility_cache, frame)) {
return 0.0;
}
const dims = self.getElementDimensions(frame);
return dims.width;
const tag = self.getTag();
if (tag == .html or tag == .body or dims.explicit_width) {
return dims.width;
}
return @max(dims.width, self.contentWidth(frame, visibility_cache));
}
pub fn getClientHeight(self: *Element, frame: *Frame) f64 {
if (!self.checkVisibilityCached(null, frame)) {
var visibility_cache: VisibilityCache = .{};
return self.getClientHeightWithCache(frame, &visibility_cache);
}
pub fn getClientHeightWithCache(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
if (!self.checkVisibilityCached(visibility_cache, frame)) {
return 0.0;
}
const dims = self.getElementDimensions(frame);
return dims.height;
const tag = self.getTag();
if (tag == .html or tag == .body or dims.explicit_height) {
return dims.height;
}
return @max(dims.height, self.contentHeight(frame, visibility_cache));
}
pub fn getBoundingClientRect(self: *Element, frame: *Frame) !*DOMRect {
@@ -1916,81 +2014,84 @@ fn upperTagName(tag_name: *String, buf: []u8) []const u8 {
pub fn getTag(self: *const Element) Tag {
return switch (self._type) {
.html => |he| switch (he._type) {
.anchor => .anchor,
.area => .area,
.base => .base,
.div => .div,
.dl => .dl,
.embed => .embed,
.form => .form,
.p => .p,
.custom => .custom,
.data => .data,
.datalist => .datalist,
.details => .details,
.dialog => .dialog,
.directory => .directory,
.iframe => .iframe,
.img => .img,
.br => .br,
.button => .button,
.canvas => .canvas,
.fieldset => .fieldset,
.font => .font,
.frameset => .frameset,
.heading => |h| h._tag,
.label => .label,
.legend => .legend,
.li => .li,
.map => .map,
.marquee => .marquee,
.ul => .ul,
.ol => .ol,
.object => .object,
.optgroup => .optgroup,
.output => .output,
.picture => .picture,
.param => .param,
.pre => .pre,
.generic => |g| g._tag,
.media => |m| switch (m._type) {
.audio => .audio,
.video => .video,
.generic => .media,
},
.meter => .meter,
.mod => |m| m._tag,
.progress => .progress,
.quote => |q| q._tag,
.script => .script,
.select => .select,
.slot => .slot,
.source => .source,
.span => .span,
.option => .option,
.table => .table,
.table_caption => .caption,
.table_cell => |tc| tc._tag,
.table_col => |tc| tc._tag,
.table_row => .tr,
.table_section => |ts| ts._tag,
.template => .template,
.textarea => .textarea,
.time => .time,
.track => .track,
.input => .input,
.link => .link,
.meta => .meta,
.hr => .hr,
.style => .style,
.title => .title,
.body => .body,
.html => .html,
.head => .head,
.unknown => .unknown,
.html => blk: {
const he = self.subtype(Html);
break :blk switch (he._type) {
.anchor => .anchor,
.area => .area,
.base => .base,
.div => .div,
.dl => .dl,
.embed => .embed,
.form => .form,
.p => .p,
.custom => .custom,
.data => .data,
.datalist => .datalist,
.details => .details,
.dialog => .dialog,
.directory => .directory,
.iframe => .iframe,
.img => .img,
.br => .br,
.button => .button,
.canvas => .canvas,
.fieldset => .fieldset,
.font => .font,
.frameset => .frameset,
.heading => he.subtype(Html.Heading)._tag,
.label => .label,
.legend => .legend,
.li => .li,
.map => .map,
.marquee => .marquee,
.ul => .ul,
.ol => .ol,
.object => .object,
.optgroup => .optgroup,
.output => .output,
.picture => .picture,
.param => .param,
.pre => .pre,
.generic => he.subtype(Html.Generic)._tag,
.media => switch (he.subtype(Html.Media)._type) {
.audio => .audio,
.video => .video,
.generic => .media,
},
.meter => .meter,
.mod => he.subtype(Html.Mod)._tag,
.progress => .progress,
.quote => he.subtype(Html.Quote)._tag,
.script => .script,
.select => .select,
.slot => .slot,
.source => .source,
.span => .span,
.option => .option,
.table => .table,
.table_caption => .caption,
.table_cell => he.subtype(Html.TableCell)._tag,
.table_col => he.subtype(Html.TableCol)._tag,
.table_row => .tr,
.table_section => he.subtype(Html.TableSection)._tag,
.template => .template,
.textarea => .textarea,
.time => .time,
.track => .track,
.input => .input,
.link => .link,
.meta => .meta,
.hr => .hr,
.style => .style,
.title => .title,
.body => .body,
.html => .html,
.head => .head,
.unknown => .unknown,
};
},
.svg => |se| se.getTag(),
.svg => self.subtype(Svg).getTag(),
};
}
@@ -2315,6 +2416,7 @@ pub const JsApi = struct {
pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{});
pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{});
pub const matches = bridge.function(Element.matches, .{});
pub const webkitMatchesSelector = bridge.function(Element.matches, .{});
pub const querySelector = bridge.function(Element.querySelector, .{});
pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{});
pub const closest = bridge.function(Element.closest, .{});
@@ -2353,22 +2455,21 @@ pub const Build = struct {
// Calls `func_name` with `args` on the most specific type where it is
// implement. This could be on the Element itself.
pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool {
inline for (@typeInfo(Element.Type).@"union".fields) |f| {
if (@field(Element.Type, f.name) == self._type) {
// The inner type implements this function. Call it and we're done.
const S = reflect.Struct(f.type);
switch (self._type) {
inline else => |tag| {
const S = Subtype(tag);
if (@hasDecl(S, "Build")) {
// The inner type has its own "call" method. Defer to it.
if (@hasDecl(S.Build, "call")) {
const sub = @field(self._type, f.name);
return S.Build.call(sub, func_name, args);
return S.Build.call(self.subtype(S), func_name, args);
}
// The inner type implements this function. Call it and we're done.
if (@hasDecl(f.type, func_name)) {
return @call(.auto, @field(f.type, func_name), args);
if (@hasDecl(S, func_name)) {
return @call(.auto, @field(S, func_name), args);
}
}
}
},
}
if (@hasDecl(Element.Build, func_name)) {
+38 -88
View File
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const EventManager = @import("../EventManager.zig");
const Node = @import("Node.zig");
const EventTarget = @import("EventTarget.zig");
@@ -40,6 +41,7 @@ _type_string: String,
_target: ?*EventTarget = null,
_current_target: ?*EventTarget = null,
_dispatch_target: ?*EventTarget = null, // Original target for composedPath()
_dispatch_related_target: ?*EventTarget = null,
_prevent_default: bool = false,
_stop_propagation: bool = false,
_stop_immediate_propagation: bool = false,
@@ -325,113 +327,61 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
else => return &.{},
};
// Build the path by walking up from target
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var stopped_at_shadow_boundary = false;
// Track closed shadow boundaries (position in path and host position)
var closed_shadow_boundary: ?struct { shadow_end: usize, host_start: usize } = null;
const frame_ = switch (exec.js.global) {
.frame => |frame| frame,
else => null,
};
const target_root = target_node.getRootNode(.{});
var node: ?*Node = target_node;
while (node) |n| {
if (path_len >= path_buffer.len) {
break;
}
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n._type == .document_fragment) {
const df = n.subtype(Node.DocumentFragment);
if (df._type == .shadow_root) {
const shadow = df._type.shadow_root;
if (!self._composed and n == target_root) {
stopped_at_shadow_boundary = true;
break;
}
// Track the first closed shadow boundary we encounter
if (shadow._mode == .closed and closed_shadow_boundary == null) {
// Mark where the shadow root is in the path
// The next element will be the host
closed_shadow_boundary = .{
.shadow_end = path_len - 1, // index of shadow root
.host_start = path_len, // index where host will be
};
}
// Jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
}
// an assigned slottable's event-path parent is its assigned slot,
// routing the event into the slot's shadow tree
if (frame_) |frame| {
if (frame._assigned_slots.get(n)) |slot| {
node = slot.asNode();
continue;
}
}
node = n._parent;
var path_buffer: [128]*EventTarget = undefined;
var path_len = EventManager.buildEventPath(target_node, self, frame_, &path_buffer).len;
if (path_len == 0) {
return &.{};
}
// Add window at the end. It only participates when propagation did not stop
// at a shadow boundary...
if (stopped_at_shadow_boundary == false) {
// ... AND when the tree's root is a document
const root_is_document = path_len > 0 and switch (path_buffer[path_len - 1]._type) {
.node => |n| n._type == .document,
else => false,
// Window follows the document at the end of the path. A path that stopped
// early — at a shadow boundary, or at the relatedTarget — doesn't end on
// the document and so doesn't reach it.
const root_is_document = switch (path_buffer[path_len - 1]._type) {
.node => |n| n._type == .document,
else => false,
};
if (root_is_document and path_len < path_buffer.len) {
if (frame_) |frame| {
path_buffer[path_len] = frame.window.asEventTarget();
path_len += 1;
}
}
// The host of the first closed shadow root on the path. Everything before
// it is inside that root and hidden from a currentTarget outside it.
var closed_host_index: ?usize = null;
for (path_buffer[0..path_len], 0..) |entry, i| {
const node = switch (entry._type) {
.node => |n| n,
else => continue,
};
if (root_is_document) {
if (path_len < path_buffer.len) {
switch (exec.js.global) {
.worker => {},
.frame => |frame| {
path_buffer[path_len] = frame.window.asEventTarget();
path_len += 1;
},
}
}
const shadow = node.is(Node.ShadowRoot) orelse continue;
if (shadow._mode == .closed) {
closed_host_index = i + 1;
break;
}
}
// Determine visible path based on current_target and closed shadow boundaries
var visible_start_index: usize = 0;
if (closed_shadow_boundary) |boundary| {
// Check if current_target is outside the closed shadow
// If current_target is null or is at/after the host position, hide shadow internals
const current_target = self._current_target;
if (current_target) |ct| {
// Find current_target in the path
var ct_index: ?usize = null;
if (closed_host_index) |host_index| {
// Find current_target in the path; if it's at or after the host, it's
// outside the closed shadow and must not see the nodes inside it.
if (self._current_target) |ct| {
for (path_buffer[0..path_len], 0..) |elem, i| {
if (elem == ct) {
ct_index = i;
if (i >= host_index) {
visible_start_index = host_index;
}
break;
}
}
// If current_target is at or after the host (outside the closed shadow),
// hide everything from target up to the host
if (ct_index) |idx| {
if (idx >= boundary.host_start) {
visible_start_index = boundary.host_start;
}
}
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ pub fn forEach(self: *EventCounts, cb_: js.Function, js_this_: ?js.Object) !void
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
for (tracked_event_types, self._counts) |event_type, count| {
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ count, event_type, self }, &caught) catch {
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "EventCounts" });
};
+1 -1
View File
@@ -119,7 +119,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 {
var started = false;
var in_whitespace = false;
var result: std.ArrayList(u8) = .empty;
try result.ensureTotalCapacity(frame.local_arena, text.len);
try result.ensureTotalCapacityPrecise(frame.local_arena, text.len);
for (text) |c| {
const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C';
+1 -1
View File
@@ -302,7 +302,7 @@ pub fn deliverEntries(self: *IntersectionObserver, frame: *Frame) !void {
}
const entries = try self.takeRecords(frame);
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
+1 -1
View File
@@ -100,7 +100,7 @@ pub fn init() KeyValueList {
}
pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void {
return self._entries.ensureTotalCapacity(allocator, n);
return self._entries.ensureTotalCapacityPrecise(allocator, n);
}
pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 {
+1 -1
View File
@@ -195,7 +195,7 @@ pub const ModelContextClient = struct {
defer ls.deinit();
const resolver = ls.local.createPromiseResolver();
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
if (callback.tryCall(js.Value, .{}, &caught)) |result| {
// The callback may itself return a thenable; resolving with its
// value lets V8's promise resolution machinery unwrap it.
+1 -1
View File
@@ -348,7 +348,7 @@ pub fn deliverRecords(self: *MutationObserver, frame: *Frame) !void {
frame.js.localScope(&ls);
defer ls.deinit();
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
ls.toLocal(self._callback).tryCallWithThis(void, self, .{ records, self }, &caught) catch |err| {
log.err(.frame, "MutObserver.deliverRecords", .{ .err = err, .caught = caught });
return err;
+16 -12
View File
@@ -464,7 +464,7 @@ pub fn getChildTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFaile
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
try writer.writeAll(text._proto._data.str());
try writer.writeAll(text.asCData()._data.str());
}
}
}
@@ -476,7 +476,7 @@ pub fn childTextContentLen(self: *Node) usize {
var it = self.childrenIterator();
while (it.next()) |child| {
if (child.is(CData.Text)) |text| {
len += text._proto._data.str().len;
len += text.asCData()._data.str().len;
}
}
return len;
@@ -636,15 +636,15 @@ pub fn isEqualChildren(a: *Node, b: *Node) bool {
return a_count == b_count;
}
// The shadow root whose tree this node belongs to, or null when it belongs to
// a document tree or a detached one. Inclusive: a shadow root is in its own
// tree.
pub fn containingShadowRoot(self: *Node) ?*ShadowRoot {
return self.getRootNode(.{}).is(ShadowRoot);
}
pub fn isInShadowTree(self: *Node) bool {
var node = self._parent;
while (node) |n| {
if (n.is(ShadowRoot) != null) {
return true;
}
node = n._parent;
}
return false;
return self.containingShadowRoot() != null;
}
pub fn isConnected(self: *const Node) bool {
@@ -1209,6 +1209,10 @@ const CloneError = error{
ExecutionTerminated,
};
pub fn cloneNode(self: *Node, deep_: ?bool, frame: *Frame) CloneError!*Node {
if (self.is(ShadowRoot) != null) {
return error.NotSupported;
}
const deep = deep_ orelse false;
switch (self._type) {
.cdata => {
@@ -1389,7 +1393,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra
continue;
};
if (text_node._proto.getData().len == 0) {
if (text_node.asCData().getData().len == 0) {
frame.removeNode(self, current_node, .{ .will_be_reconnected = false });
child = next_node;
continue;
@@ -1407,7 +1411,7 @@ fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayList(u8), fra
next_node = node_to_merge.nextSibling();
frame.removeNode(self, to_remove, .{ .will_be_reconnected = false });
}
text_node._proto._data = try frame.dupeSSO(buffer.items);
text_node.asCData()._data = try frame.dupeSSO(buffer.items);
buffer.clearRetainingCapacity();
}
}
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn dispatch(self: *PerformanceObserver) !void {
self._js.localScope(&ls);
defer ls.deinit();
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
ls.toLocal(self._callback).tryCall(void, .{ EntryList{ ._entries = records }, self }, &caught) catch |err| {
log.err(.frame, "PerfObserver.dispatch", .{ .err = err, .caught = caught });
return err;
+1 -1
View File
@@ -364,7 +364,7 @@ pub fn insertNode(self: *Range, node: *Node, frame: *Frame) !void {
// records browsers do (one for the split-off node, one for
// the inserted node).
const second = try t.splitText(offset, frame);
_ = try parent.insertBefore(node, second._proto.asNode(), frame);
_ = try parent.insertBefore(node, second.asCData().asNode(), frame);
} else {
_ = try parent.insertBefore(node, container.nextSibling(), frame);
}
+1 -1
View File
@@ -183,7 +183,7 @@ pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void {
return;
}
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
+18
View File
@@ -124,6 +124,23 @@ pub fn setOnSlotChange(self: *ShadowRoot, callback: ?js.Function.Global, frame:
}
}
pub fn getActiveElement(self: *ShadowRoot, frame: *Frame) ?*Element {
const root = self.asNode();
const document = root.ownerDocument(frame) orelse frame.document;
// This is answering two questions:
// 1 - is the active element contained by me (if not, return null)
// 2 - if it is, is there 1+ other shadowroot between us
// a - if there is, return the nearest (to self) shadowroot's host
// b - if there isn't, return the active element
var candidate = document._active_element orelse return null;
while (candidate.asNode().getRootNode(.{}) != root) {
const shadow = candidate.asNode().containingShadowRoot() orelse return null;
candidate = shadow._host;
}
return candidate;
}
pub fn getElementById(self: *ShadowRoot, id: []const u8, frame: *Frame) ?*Element {
if (id.len == 0) {
return null;
@@ -177,6 +194,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const activeElement = bridge.accessor(ShadowRoot.getActiveElement, null, .{});
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
pub const delegatesFocus = bridge.accessor(ShadowRoot.getDelegatesFocus, null, .{});
@@ -212,7 +212,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
if (transfer.getContentLength()) |cl| {
try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl);
try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl);
}
return .proceed;
+2 -2
View File
@@ -60,8 +60,8 @@ pub fn getComputedLabel(_: *const WebDriver, element: *Element, frame: *Frame) !
pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void {
if (element.is(Element.Html)) |html| {
switch (html._type) {
inline .button, .input, .textarea, .select => |i| {
if (i.getDisabled()) {
inline .button, .input, .textarea, .select => |tag| {
if (html.subtype(Element.Html.Subtype(tag)).getDisabled()) {
return;
}
},
+9 -9
View File
@@ -639,6 +639,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
// We still dispatch so that addEventListener('error', ...) listeners fire.
try frame._event_manager.dispatchDirect(target, event, null, .{
.context = "window.reportError",
.run_microtasks = false,
});
if (comptime lp.IS_TEST == false) {
@@ -661,16 +662,15 @@ pub fn matchMedia(_: *const Window, query: []const u8, frame: *Frame) !*MediaQue
}
pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties {
if (pseudo_element) |pe| {
if (pe.len != 0) {
log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe });
// Chrome hands out a distinct object per pseudo-element, so these
// can't share the per-element cache entry.
return CSSStyleProperties.init(element, true, frame);
}
}
const gop = try frame._element_computed_styles.getOrPut(frame.arena, element);
// :before/:after get their own cache entry and no warning: our answer
// (the element's own computed style) is a reasonable default for the
// common probes
const pseudo = Element.PseudoElement.parse(pseudo_element orelse "");
const gop = try frame._element_computed_styles.getOrPut(frame.arena, .{ .element = element, .pseudo = pseudo });
if (!gop.found_existing) {
if (pseudo == .other) {
log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pseudo_element.? });
}
gop.value_ptr.* = try CSSStyleProperties.init(element, true, frame);
}
return gop.value_ptr.*;
+1 -1
View File
@@ -170,7 +170,7 @@ fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
}
if (transfer.getContentLength()) |cl| {
try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl);
try self._script_buffer.ensureTotalCapacityPrecise(self._script_arena.?.allocator(), cl);
}
return .proceed;
+4 -1
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Text = @import("Text.zig");
@@ -24,7 +26,8 @@ const CDATASection = @This();
pub const Proto = Text;
_proto: *Text,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *Text else void = undefined,
pub const JsApi = struct {
pub const bridge = js.Bridge(CDATASection);
+4 -1
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
@@ -25,7 +27,8 @@ const Comment = @This();
pub const Proto = CData;
_proto: *CData,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
pub fn init(str: ?js.NullableString, frame: *Frame) !*Comment {
const node = try Frame.node_factory.createComment(frame, if (str) |s| s.value else "");
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const CData = @import("../CData.zig");
@@ -24,7 +26,7 @@ const ProcessingInstruction = @This();
pub const Proto = CData;
_proto: *CData,
_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
_target: []const u8,
pub fn getTarget(self: *const ProcessingInstruction) []const u8 {
+17 -9
View File
@@ -17,8 +17,10 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Factory = @import("../../Factory.zig");
const Frame = @import("../../Frame.zig");
const Node = @import("../Node.zig");
const CData = @import("../CData.zig");
@@ -29,7 +31,13 @@ const Text = @This();
pub const Proto = CData;
_proto: *CData,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *CData else void = undefined,
// Takes a const text but returns a mutable proto; see Node.subtype.
pub fn asCData(self: *const Text) *CData {
return Factory.protoOf(self);
}
pub fn init(str: ?js.NullableString, frame: *Frame) !*Text {
const node = try Frame.node_factory.createTextNode(frame, if (str) |s| s.value else "");
@@ -38,13 +46,13 @@ pub fn init(str: ?js.NullableString, frame: *Frame) !*Text {
// This Text node's own data (getWholeText below spans adjacent Text nodes).
pub fn ownData(self: *const Text) []const u8 {
return self._proto._data.str();
return Factory.protoOf(self)._data.str();
}
// The concatenated data of the contiguous exclusive Text nodes (adjacent
// Text siblings on both sides of this one), in tree order.
pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 {
const node = self._proto.asNode();
const node = Factory.protoOf(self).asNode();
var first = node;
while (first.previousSibling()) |prev| {
@@ -57,7 +65,7 @@ pub fn getWholeText(self: *Text, frame: *Frame) ![]const u8 {
// Common case: no adjacent text nodes, return our data directly.
const has_next_text = if (node.nextSibling()) |next| isExclusiveTextNode(next) else false;
if (first == node and !has_next_text) {
return self._proto._data.str();
return Factory.protoOf(self)._data.str();
}
var buf: std.ArrayList(u8) = .empty;
@@ -74,11 +82,11 @@ fn isExclusiveTextNode(node: *Node) bool {
}
pub fn getAssignedSlot(self: *Text, frame: *Frame) ?*Slot {
return slotting.findSlot(self._proto.asNode(), true, frame);
return slotting.findSlot(Factory.protoOf(self).asNode(), true, frame);
}
pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
const data = self._proto._data.str();
const data = Factory.protoOf(self)._data.str();
const byte_offset = CData.utf16OffsetToUtf8(data, offset) catch return error.IndexSizeError;
@@ -86,7 +94,7 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
const new_node = try Frame.node_factory.createTextNode(frame, new_data);
const new_text = new_node.as(Text);
const node = self._proto.asNode();
const node = Factory.protoOf(self).asNode();
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
// then truncate original node (step 8).
@@ -103,8 +111,8 @@ pub fn splitText(self: *Text, offset: usize, frame: *Frame) !*Text {
// Step 8: truncate original node via replaceData(offset, count, "").
// Use replaceData instead of setData so live range updates fire
// (matters for detached text nodes where steps 7b-7e were skipped).
const length = self._proto.getLength();
try self._proto.replaceData(offset, length - offset, "", frame);
const length = Factory.protoOf(self).getLength();
try Factory.protoOf(self).replaceData(offset, length - offset, "", frame);
return new_text;
}
@@ -246,7 +246,7 @@ pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, fram
if (gop.found_existing) {
continue;
}
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ token, i, self }, &caught) catch |err| {
frame._page.recordJsError(err);
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "DOMTokenList" });
+1 -1
View File
@@ -97,7 +97,7 @@ pub fn forEach(self: *NodeList, cb: js.Function, frame: *Frame) !void {
while (true) : (i += 1) {
const node = try self.getAtIndex(@intCast(i), frame) orelse return;
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
cb.tryCall(void, .{ node, i, self }, &caught) catch |err| {
frame._page.recordJsError(err);
log.debug(.js, "forEach callback", .{ .caught = caught, .source = "nodelist" });
+1 -1
View File
@@ -407,7 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type {
fn isFormControl(el: *Element) bool {
if (el._type != .html) return false;
const html = el._type.html;
const html = el.subtype(Element.Html);
return switch (html._type) {
.input, .button, .select, .textarea => true,
else => false,
+13 -11
View File
@@ -97,9 +97,11 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
}
// Computed width/height must agree with the synthetic layout
// metrics (offsetWidth/getBoundingClientRect). Returning ""
// makes measurement code see contradictory sizes — jQuery's
// "shrink text until it fits" loops then never terminate.
// metrics. Returning "" makes measurement code see
// contradictory sizes — jQuery's "shrink text until it fits"
// loops then never terminate. jQuery's .width() reads this
// value, so it must also carry clientWidth's content fallback
// or append-until-wide marquee loops never terminate.
if (wrapped.eql(comptime .wrap("width"))) {
return resolvedDimension(element, .width, frame);
}
@@ -115,13 +117,13 @@ pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const
}
fn resolvedDimension(element: *Element, dimension: enum { width, height }, frame: *Frame) []const u8 {
if (!element.checkVisibilityCached(null, frame)) {
var visibility_cache: Element.VisibilityCache = .{};
if (!element.checkVisibilityCached(&visibility_cache, frame)) {
return "auto";
}
const dims = element.getElementDimensions(frame);
const value = switch (dimension) {
.width => dims.width,
.height => dims.height,
.width => element.getClientWidthWithCache(frame, &visibility_cache),
.height => element.getClientHeightWithCache(frame, &visibility_cache),
};
return std.fmt.allocPrint(frame.local_arena, "{d}px", .{value}) catch "auto";
}
@@ -802,8 +804,8 @@ fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, name: String) []con
fn getDefaultDisplay(element: *const Element) []const u8 {
switch (element._type) {
.html => |html| {
return switch (html._type) {
.html => {
return switch (element.subtype(Element.Html)._type) {
.anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline",
.body, .div, .dl, .p, .heading, .form, .button, .canvas, .details, .dialog, .embed, .head, .html, .hr, .iframe, .img, .input, .li, .link, .meta, .ol, .option, .script, .select, .slot, .style, .template, .textarea, .title, .ul, .media, .area, .base, .datalist, .directory, .fieldset, .frameset, .legend, .map, .marquee, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block",
.generic, .custom, .unknown, .data => blk: {
@@ -835,8 +837,8 @@ fn isInlineTag(tag_name: []const u8) bool {
fn getDefaultColor(element: *const Element) []const u8 {
switch (element._type) {
.html => |html| {
return switch (html._type) {
.html => {
return switch (element.subtype(Element.Html)._type) {
.anchor => "rgb(0, 0, 238)", // blue
else => "rgb(0, 0, 0)",
};
+1 -1
View File
@@ -353,7 +353,7 @@ pub const List = struct {
pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 {
var arr: std.ArrayList([]const u8) = .empty;
try arr.ensureTotalCapacity(allocator, self._len);
try arr.ensureTotalCapacityPrecise(allocator, self._len);
for (self.entries()) |*e| {
arr.appendAssumeCapacity(e.name());
}
+2 -2
View File
@@ -81,7 +81,7 @@ fn camelToKebab(arena: Allocator, camel: String) !String {
// Fallback: allocate for longer strings
var result: std.ArrayList(u8) = .empty;
try result.ensureTotalCapacity(arena, output_len);
try result.ensureTotalCapacityPrecise(arena, output_len);
result.appendSliceAssumeCapacity("data-");
for (camel_str, 0..) |c, i| {
@@ -110,7 +110,7 @@ fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 {
const data_part = kebab[5..]; // Skip "data-"
var result: std.ArrayList(u8) = .empty;
try result.ensureTotalCapacity(arena, data_part.len);
try result.ensureTotalCapacityPrecise(arena, data_part.len);
var i: usize = 0;
while (i < data_part.len) : (i += 1) {
+178 -88
View File
@@ -116,6 +116,10 @@ _proto_canary: if (lp.IS_DEBUG) *Element else void = undefined,
// which custom element class was invoked; look it up in the registry.
pub fn construct(new_target: js.Function, frame: *Frame) !*Element {
if (frame._upgrading_element) |node| {
if (frame._upgrading_consumed) {
return error.TypeError;
}
frame._upgrading_consumed = true;
return node.is(Element) orelse return error.IllegalConstructor;
}
return Frame.node_factory.constructCustomElement(frame, new_target);
@@ -127,90 +131,176 @@ pub fn construct(new_target: js.Function, frame: *Frame) !*Element {
// constructors routed here.
pub fn upgradeConstruct(frame: *Frame) !*Element {
const node = frame._upgrading_element orelse return error.TypeError;
if (frame._upgrading_consumed) {
return error.TypeError;
}
frame._upgrading_consumed = true;
return node.is(Element) orelse return error.TypeError;
}
pub const Type = union(enum) {
anchor: *Anchor,
area: *Area,
base: *Base,
body: *Body,
br: *BR,
button: *Button,
canvas: *Canvas,
custom: *Custom,
data: *Data,
datalist: *DataList,
details: *Details,
dialog: *Dialog,
directory: *Directory,
div: *Div,
dl: *DList,
embed: *Embed,
fieldset: *FieldSet,
font: *Font,
form: *Form,
frameset: *FrameSet,
generic: *Generic,
heading: *Heading,
head: *Head,
html: *Html,
hr: *HR,
img: *Image,
iframe: *IFrame,
input: *Input,
label: *Label,
legend: *Legend,
li: *LI,
link: *Link,
map: *Map,
marquee: *Marquee,
media: *Media,
meta: *Meta,
meter: *Meter,
mod: *Mod,
object: *Object,
ol: *OL,
optgroup: *OptGroup,
option: *Option,
output: *Output,
p: *Paragraph,
picture: *Picture,
param: *Param,
pre: *Pre,
progress: *Progress,
quote: *Quote,
script: *Script,
select: *Select,
slot: *Slot,
source: *Source,
span: *Span,
style: *Style,
table: *Table,
table_caption: *TableCaption,
table_cell: *TableCell,
table_col: *TableCol,
table_row: *TableRow,
table_section: *TableSection,
template: *Template,
textarea: *TextArea,
time: *Time,
title: *Title,
track: *Track,
ul: *UL,
unknown: *Unknown,
pub const Type = enum(u8) {
anchor,
area,
base,
body,
br,
button,
canvas,
custom,
data,
datalist,
details,
dialog,
directory,
div,
dl,
embed,
fieldset,
font,
form,
frameset,
generic,
heading,
head,
html,
hr,
img,
iframe,
input,
label,
legend,
li,
link,
map,
marquee,
media,
meta,
meter,
mod,
object,
ol,
optgroup,
option,
output,
p,
picture,
param,
pre,
progress,
quote,
script,
select,
slot,
source,
span,
style,
table,
table_caption,
table_cell,
table_col,
table_row,
table_section,
template,
textarea,
time,
title,
track,
ul,
unknown,
};
pub fn Subtype(comptime tag: Type) type {
return switch (tag) {
.anchor => Anchor,
.area => Area,
.base => Base,
.body => Body,
.br => BR,
.button => Button,
.canvas => Canvas,
.custom => Custom,
.data => Data,
.datalist => DataList,
.details => Details,
.dialog => Dialog,
.directory => Directory,
.div => Div,
.dl => DList,
.embed => Embed,
.fieldset => FieldSet,
.font => Font,
.form => Form,
.frameset => FrameSet,
.generic => Generic,
.heading => Heading,
.head => Head,
.html => Html,
.hr => HR,
.img => Image,
.iframe => IFrame,
.input => Input,
.label => Label,
.legend => Legend,
.li => LI,
.link => Link,
.map => Map,
.marquee => Marquee,
.media => Media,
.meta => Meta,
.meter => Meter,
.mod => Mod,
.object => Object,
.ol => OL,
.optgroup => OptGroup,
.option => Option,
.output => Output,
.p => Paragraph,
.picture => Picture,
.param => Param,
.pre => Pre,
.progress => Progress,
.quote => Quote,
.script => Script,
.select => Select,
.slot => Slot,
.source => Source,
.span => Span,
.style => Style,
.table => Table,
.table_caption => TableCaption,
.table_cell => TableCell,
.table_col => TableCol,
.table_row => TableRow,
.table_section => TableSection,
.template => Template,
.textarea => TextArea,
.time => Time,
.title => Title,
.track => Track,
.ul => UL,
.unknown => Unknown,
};
}
pub fn subtype(self: *const HtmlElement, comptime T: type) *T {
const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, HtmlElement);
const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
if (comptime lp.IS_DEBUG) {
// This pointer dance only works because the factory allocates the chain
// in a contiguous block of memory. In debug, we assert this holds via
// the _proto_canary back pointer.
std.debug.assert(Factory.protoOf(sub) == self);
}
return sub;
}
pub fn is(self: *HtmlElement, comptime T: type) ?*T {
inline for (@typeInfo(Type).@"union".fields) |f| {
if (@field(Type, f.name) == self._type) {
if (f.type == T) {
return &@field(self._type, f.name);
switch (self._type) {
inline else => |tag| {
if (Subtype(tag) == T) {
return self.subtype(T);
}
if (f.type == *T) {
return @field(self._type, f.name);
}
}
},
}
return null;
}
@@ -304,8 +394,8 @@ pub fn insertAdjacentHTML(
pub fn click(self: *HtmlElement, frame: *Frame) !void {
switch (self._type) {
inline .button, .input, .textarea, .select => |i| {
if (i.getDisabled()) {
inline .button, .input, .textarea, .select => |tag| {
if (self.subtype(Subtype(tag)).getDisabled()) {
return;
}
},
@@ -330,8 +420,8 @@ pub fn click(self: *HtmlElement, frame: *Frame) !void {
if (event._prevent_default == false) {
// toggle the popover_target
const explicit: ?*Element = switch (self._type) {
.button => |b| b._popover_target,
.input => |i| i._popover_target,
.button => self.subtype(Button)._popover_target,
.input => self.subtype(Input)._popover_target,
else => null,
};
try popover.runInvokerActivation(self, explicit, frame);
@@ -543,7 +633,7 @@ fn setAttributeListener(
) !void {
if (comptime lp.IS_DEBUG) {
log.debug(.event, "Html.setAttributeListener", .{
.type = std.meta.activeTag(self._type),
.type = self._type,
.listener_type = listener_type,
});
}
@@ -1492,14 +1582,14 @@ fn collectInnerText(self: *HtmlElement, state: *InnerTextState) std.Io.Writer.Er
const e = child.subtype(Node.Element);
switch (e._type) {
.svg => {},
.html => |he| {
.html => {
const tag = e.getTag();
switch (child_filter) {
.none => {},
.select => if (tag != .option and tag != .optgroup) continue,
.optgroup => if (tag != .option) continue,
}
try handleChildElement(he, tag, state, &saw_cell, &saw_row);
try handleChildElement(e.subtype(HtmlElement), tag, state, &saw_cell, &saw_row);
},
}
},
@@ -1870,17 +1960,17 @@ pub const Build = struct {
// Calls `func_name` with `args` on the most specific type where it is
// implement. This could be on the HtmlElement itself.
pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool {
inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| {
if (@field(HtmlElement.Type, f.name) == self._type) {
switch (self._type) {
inline else => |tag| {
const S = Subtype(tag);
// The inner type implements this function. Call it and we're done.
const S = reflect.Struct(f.type);
if (@hasDecl(S, "Build")) {
if (@hasDecl(S.Build, func_name)) {
try @call(.auto, @field(S.Build, func_name), args);
return true;
}
}
}
},
}
if (@hasDecl(HtmlElement.Build, func_name)) {
+78 -42
View File
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
@@ -47,64 +48,99 @@ _type: Type,
_tag_name: String, // Svg elements are case-preserving
_proto_canary: if (lp.IS_DEBUG) *Element else void = undefined,
pub const Type = union(enum) {
graphics: *Graphics,
view: *View,
title: *Title,
desc: *Desc,
metadata: *Metadata,
gradient: *GradientElement,
clip_path: *ClipPath,
marker: *Marker,
mask: *Mask,
pattern: *Pattern,
stop: *Stop,
generic: *Generic,
pub const Type = enum(u8) {
graphics,
view,
title,
desc,
metadata,
gradient,
clip_path,
marker,
mask,
pattern,
stop,
generic,
};
pub fn Subtype(comptime tag: Type) type {
return switch (tag) {
.graphics => Graphics,
.view => View,
.title => Title,
.desc => Desc,
.metadata => Metadata,
.gradient => GradientElement,
.clip_path => ClipPath,
.marker => Marker,
.mask => Mask,
.pattern => Pattern,
.stop => Stop,
.generic => Generic,
};
}
pub fn subtype(self: *const Svg, comptime T: type) *T {
const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Svg);
const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
if (comptime lp.IS_DEBUG) {
// This pointer dance only works because the factory allocates the chain
// in a contiguous block of memory. In debug, we assert this holds via
// the _proto_canary back pointer.
std.debug.assert(Factory.protoOf(sub) == self);
}
return sub;
}
pub fn is(self: *Svg, comptime T: type) ?*T {
inline for (@typeInfo(Type).@"union".fields) |field| {
if (@field(Type, field.name) == self._type) {
if (field.type == *T) {
return @field(self._type, field.name);
switch (self._type) {
inline else => |tag| {
if (Subtype(tag) == T) {
return self.subtype(T);
}
}
},
}
if (self._type == .graphics) {
return self._type.graphics.is(T);
return self.subtype(Graphics).is(T);
}
if (self._type == .gradient) {
return self._type.gradient.is(T);
return self.subtype(GradientElement).is(T);
}
return null;
}
pub fn getTag(self: *const Svg) Element.Tag {
return switch (self._type) {
.graphics => |g| switch (g._type) {
.svg => .svg,
.g => .g,
// No dedicated Element.Tag values; tag-name matching falls back
// to _tag_name, like it does for generic SVG elements.
.a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown,
.text_content => |content| switch (content._type) {
.positioning => |positioning| switch (positioning._type) {
.text => .text,
.tspan => .unknown,
.graphics => blk: {
const g = self.subtype(Graphics);
break :blk switch (g._type) {
.svg => .svg,
.g => .g,
// No dedicated Element.Tag values; tag-name matching falls back
// to _tag_name, like it does for generic SVG elements.
.a, .use, .image, .defs, .symbol, .switch_element, .foreign_object => .unknown,
.text_content => tc: {
const content = g.subtype(Graphics.TextContent);
break :tc switch (content._type) {
.positioning => switch (content.subtype(Graphics.TextContent.TextPositioning)._type) {
.text => .text,
.tspan => .unknown,
},
.text_path => .unknown,
};
},
.text_path => .unknown,
},
.geometry => |geo| switch (geo._type) {
.rect => .rect,
.circle => .circle,
.ellipse => .ellipse,
.line => .line,
.path => .path,
.polygon => .polygon,
.polyline => .polyline,
},
.geometry => switch (g.subtype(Graphics.Geometry)._type) {
.rect => .rect,
.circle => .circle,
.ellipse => .ellipse,
.line => .line,
.path => .path,
.polygon => .polygon,
.polyline => .polyline,
},
};
},
.generic => |g| g._tag,
.generic => self.subtype(Generic)._tag,
.title => .title,
.view, .desc, .metadata, .gradient, .clip_path, .marker, .mask, .pattern, .stop => .unknown,
};
+6 -3
View File
@@ -16,8 +16,10 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const URL = @import("../../../URL.zig");
@@ -28,13 +30,14 @@ const HtmlElement = @import("../Html.zig");
const Anchor = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Anchor) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Anchor) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Anchor) *Node {
return self.asElement().asNode();
+6 -3
View File
@@ -13,8 +13,10 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const URL = @import("../../../URL.zig");
@@ -28,13 +30,14 @@ const Area = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Area) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Area) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Area) *Node {
return self.asElement().asNode();
+5 -3
View File
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -31,7 +32,8 @@ const Audio = @This();
pub const Proto = Media;
_proto: *Media,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *Media else void = undefined,
pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media {
const node = try Frame.node_factory.createElementNS(frame, .html, "audio", null);
@@ -48,11 +50,11 @@ pub fn constructor(maybe_url: ?String, frame: *Frame) !*Media {
}
pub fn asMedia(self: *Audio) *Media {
return self._proto;
return Factory.protoOf(self);
}
pub fn asElement(self: *Audio) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Audio) *Node {
+5 -2
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const BR = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *BR) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *BR) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const URL = @import("../../../URL.zig");
const Frame = @import("../../../Frame.zig");
@@ -10,10 +12,11 @@ const Base = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Base) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Base) *Node {
return self.asElement().asNode();
+4 -2
View File
@@ -18,6 +18,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -31,10 +32,11 @@ const Body = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Body) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Body) *Node {
return self.asElement().asNode();
+5 -3
View File
@@ -16,9 +16,11 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -34,16 +36,16 @@ const Button = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_custom_validity: ?[]const u8 = null,
_validity: ?*ValidityState = null,
_popover_target: ?*Element = null,
pub fn asElement(self: *Button) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Button) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Button) *Node {
return self.asElement().asNode();
+5 -3
View File
@@ -16,8 +16,10 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -32,14 +34,14 @@ const Execution = js.Execution;
const Canvas = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_cached: ?DrawingContext = null,
pub fn asElement(self: *Canvas) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Canvas) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Canvas) *Node {
return self.asElement().asNode();
+22 -11
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -35,14 +36,15 @@ const String = lp.String;
const Custom = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_tag_name: String,
_definition: ?*CustomElementDefinition,
_connected_callback_invoked: bool = false,
_disconnected_callback_invoked: bool = false,
_upgrade_failed: bool = false, // a failed upgrade is never retried
pub fn asElement(self: *Custom) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Custom) *Node {
return self.asElement().asNode();
@@ -61,6 +63,9 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
if (element.is(Custom)) |custom| {
// Upgrade if a definition exists but isn't yet attached
if (custom._definition == null) {
if (custom._upgrade_failed) {
return;
}
const name = custom._tag_name.str();
if (frame.window._custom_elements._definitions.get(name)) |definition| {
const CustomElementRegistry = @import("../../CustomElementRegistry.zig");
@@ -256,29 +261,35 @@ pub fn checkAndAttachBuiltIn(element: *Element, frame: *Frame) !void {
// Invoke constructor
const prev_upgrading = frame._upgrading_element;
const prev_consumed = frame._upgrading_consumed;
const node = element.asNode();
frame._upgrading_element = node;
defer frame._upgrading_element = prev_upgrading;
frame._upgrading_consumed = false;
defer {
frame._upgrading_element = prev_upgrading;
frame._upgrading_consumed = prev_consumed;
}
// PERFORMANCE OPTIMIZATION: This pattern is discouraged in general code.
// Used here because: (1) multiple early returns before needing Local,
// (2) called from both V8 callbacks (Local exists) and parser (no Local).
// Prefer either: requiring *const js.Local parameter, OR always creating
// Local.Scope upfront.
var ls: ?js.Local.Scope = null;
var local = blk: {
var ls: js.Local.Scope = undefined;
var ls_open = false;
const local = blk: {
if (frame.js.local) |l| {
break :blk l;
}
ls = undefined;
frame.js.localScope(&ls.?);
break :blk &ls.?.local;
frame.js.localScope(&ls);
ls_open = true;
break :blk &ls.local;
};
defer if (ls) |*_ls| {
_ls.deinit();
defer if (ls_open) {
ls.deinit();
};
var caught: js.TryCatch.Caught = undefined;
var caught: js.TryCatch.Caught = .{};
_ = local.toLocal(definition.constructor).newInstance(&caught) catch |err| {
log.warn(.js, "custom builtin ctor", .{ .name = is_value, .err = err, .caught = caught });
return;
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const DList = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *DList) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *DList) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -27,10 +29,11 @@ const Data = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Data) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Data) *Node {
+5 -2
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -7,10 +9,11 @@ const DataList = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *DataList) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *DataList) *Node {
return self.asElement().asNode();
+6 -3
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -9,13 +11,14 @@ const Details = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Details) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Details) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Details) *Node {
return self.asElement().asNode();
+6 -3
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
@@ -10,13 +12,14 @@ const Dialog = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Dialog) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Dialog) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Dialog) *Node {
return self.asElement().asNode();
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Directory = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Directory) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Directory) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Div = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Div) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Div) *Node {
return self.asElement().asNode();
+6 -3
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -25,13 +27,14 @@ const HtmlElement = @import("../Html.zig");
const Embed = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Embed) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Embed) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Embed) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const FieldSet = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *FieldSet) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *FieldSet) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -1,3 +1,5 @@
const lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -8,10 +10,11 @@ const Font = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Font) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Font) *Node {
return self.asElement().asNode();
+6 -4
View File
@@ -16,8 +16,10 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -33,7 +35,7 @@ pub const TextArea = @import("TextArea.zig");
const Form = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
// Prevents submission of the form while we're in the process of submitting
// the form. You can imagine an onsubmit = () => form.submit() endless loop.
@@ -44,13 +46,13 @@ _firing_submission_events: bool = false,
_constructing_entry_list: bool = false,
pub fn asHtmlElement(self: *Form) *HtmlElement {
return self._proto;
return Factory.protoOf(self);
}
fn asConstElement(self: *const Form) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asElement(self: *Form) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Form) *Node {
return self.asElement().asNode();
+4 -2
View File
@@ -1,6 +1,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -13,10 +14,11 @@ const FrameSet = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *FrameSet) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *FrameSet) *Node {
return self.asElement().asNode();
+3 -2
View File
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -31,10 +32,10 @@ const Generic = @This();
pub const Proto = HtmlElement;
_tag_name: String,
_tag: Element.Tag,
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Generic) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Generic) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const HR = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *HR) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *HR) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Head = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Head) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Head) *Node {
return self.asElement().asNode();
+3 -2
View File
@@ -19,6 +19,7 @@
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -29,12 +30,12 @@ const String = lp.String;
const Heading = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_tag_name: String,
_tag: Element.Tag,
pub fn asElement(self: *Heading) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Heading) *Node {
return self.asElement().asNode();
+5 -2
View File
@@ -16,6 +16,8 @@
// 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 lp = @import("lightpanda");
const Factory = @import("../../../Factory.zig");
const js = @import("../../../js/js.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -24,10 +26,11 @@ const HtmlElement = @import("../Html.zig");
const Html = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *Html) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Html) *Node {
return self.asElement().asNode();
+43 -2
View File
@@ -16,9 +16,11 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -29,16 +31,17 @@ const DOMTokenList = @import("../../collections.zig").DOMTokenList;
const HtmlElement = @import("../Html.zig");
const String = lp.String;
const IFrame = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_src: []const u8 = "",
_executed: bool = false,
_window: ?*Window = null,
pub fn asElement(self: *IFrame) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *IFrame) *Node {
return self.asElement().asNode();
@@ -77,6 +80,19 @@ pub fn setSrc(self: *IFrame, src: []const u8, frame: *Frame) !void {
}
}
pub fn hasSrcdoc(self: *IFrame) bool {
return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) != null;
}
pub fn getSrcdoc(self: *IFrame) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("srcdoc")) orelse "";
}
pub fn setSrcdoc(self: *IFrame, value: []const u8, frame: *Frame) !void {
// Build.attributeChange triggers the (re)navigation.
try self.asElement().setAttributeSafe(comptime .wrap("srcdoc"), .wrap(value), frame);
}
pub fn getName(self: *IFrame) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse "";
}
@@ -103,6 +119,7 @@ pub const JsApi = struct {
};
pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{ .ce_reactions = true });
pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true });
pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true });
pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{});
pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{});
@@ -115,4 +132,28 @@ pub const Build = struct {
const element = self.asElement();
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
}
pub fn attributeChange(element: *Element, name: String, _: String, frame: *Frame) !void {
if (!name.eql(comptime .wrap("srcdoc"))) {
return;
}
if (element.asNode().isConnected()) {
// like src, setting srcdoc reloads the frame even if the value didn't change
const self = element.as(IFrame);
self._executed = false;
try frame.iframeAddedCallback(self);
}
}
pub fn attributeRemove(element: *Element, name: String, frame: *Frame) !void {
if (!name.eql(comptime .wrap("srcdoc"))) {
return;
}
if (element.asNode().isConnected()) {
const self = element.as(IFrame);
// removing srcdoc falls back to src (or about:blank)
self._executed = false;
try frame.iframeAddedCallback(self);
}
}
};
+7 -4
View File
@@ -1,5 +1,7 @@
const lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -8,7 +10,8 @@ const HtmlElement = @import("../Html.zig");
const Image = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image {
const node = try Frame.node_factory.createElementNS(frame, .html, "img", null);
@@ -26,10 +29,10 @@ pub fn constructor(w_: ?u32, h_: ?u32, frame: *Frame) !*Image {
}
pub fn asElement(self: *Image) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Image) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Image) *Node {
return self.asElement().asNode();
@@ -130,7 +133,7 @@ pub fn imageAddedCallback(self: *Image, frame: *Frame) !void {
const src = element.getAttributeSafe(comptime .wrap("src")) orelse return;
if (src.len == 0) return;
try frame.queueLoad(self._proto);
try frame.queueLoad(Factory.protoOf(self));
}
pub const JsApi = struct {
+19 -3
View File
@@ -20,6 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
@@ -80,7 +81,7 @@ pub const Type = enum {
}
};
_proto: *HtmlElement,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
_default_value: ?[]const u8 = null,
_default_checked: bool = false,
_value: ?[]const u8 = null,
@@ -122,10 +123,10 @@ fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, f
}
pub fn asElement(self: *Input) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asConstElement(self: *const Input) *const Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *Input) *Node {
return self.asElement().asNode();
@@ -891,6 +892,20 @@ pub fn getLabels(self: *Input, frame: *Frame) !js.Array {
return @import("Label.zig").getControlLabels(self.asElement(), frame);
}
pub fn getList(self: *Input, frame: *Frame) ?*HtmlElement.DataList {
switch (self._input_type) {
.hidden, .password, .checkbox, .radio, .file, .submit, .image, .reset, .button => return null,
else => {},
}
const element = self.asElement();
const list_id = element.getAttributeSafe(comptime .wrap("list")) orelse return null;
// list= resolves in the input's own tree (shadow root or document).
const target = frame.getElementByIdFromNode(element.asNode(), list_id) orelse return null;
return target.is(HtmlElement.DataList);
}
pub fn getForm(self: *Input, frame: *Frame) ?*Form {
const element = self.asElement();
@@ -1426,6 +1441,7 @@ pub const JsApi = struct {
pub const size = bridge.accessor(Input.getSize, Input.setSize, .{ .ce_reactions = true });
pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{ .ce_reactions = true });
pub const form = bridge.accessor(Input.getForm, null, .{});
pub const list = bridge.accessor(Input.getList, null, .{});
pub const formAction = bridge.accessor(Input.getFormAction, Input.setFormAction, .{});
pub const formEnctype = bridge.accessor(Input.getFormEnctype, Input.setFormEnctype, .{});
pub const formMethod = bridge.accessor(Input.getFormMethod, Input.setFormMethod, .{});
+5 -2
View File
@@ -16,8 +16,10 @@
// 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 lp = @import("lightpanda");
const std = @import("std");
const js = @import("../../../js/js.zig");
const Factory = @import("../../../Factory.zig");
const Frame = @import("../../../Frame.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
@@ -26,10 +28,11 @@ const HtmlElement = @import("../Html.zig");
const LI = @This();
pub const Proto = HtmlElement;
_proto: *HtmlElement,
_pad: bool = false,
_proto_canary: if (lp.IS_DEBUG) *HtmlElement else void = undefined,
pub fn asElement(self: *LI) *Element {
return self._proto.asElement();
return Factory.protoOf(self).asElement();
}
pub fn asNode(self: *LI) *Node {
return self.asElement().asNode();
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff. Show more