mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 07:33:16 +00:00
Compare commits
198 Commits
v0.2.5
...
structured
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6747182945 | ||
|
|
7d835ef99d | ||
|
|
0971df4dfc | ||
|
|
9fb57fbac0 | ||
|
|
48ead90850 | ||
|
|
cc88bb7feb | ||
|
|
a725e2aa6a | ||
|
|
c8f8d79f45 | ||
|
|
25c89c9940 | ||
|
|
697a2834c2 | ||
|
|
056b8bb536 | ||
|
|
625d424199 | ||
|
|
d2c55da6c9 | ||
|
|
c891eff664 | ||
|
|
68564ca714 | ||
|
|
ff26b0d5a4 | ||
|
|
487ee18358 | ||
|
|
dc3d2e9790 | ||
|
|
f6d0e484b0 | ||
|
|
4cea9aba3c | ||
|
|
7348a68c84 | ||
|
|
7d90c3f582 | ||
|
|
2a103fc94a | ||
|
|
753391b7e2 | ||
|
|
94ce5edd20 | ||
|
|
3626f70d3e | ||
|
|
24cc24ed50 | ||
|
|
dd29ba4664 | ||
|
|
7927ad8fcf | ||
|
|
d23453ce45 | ||
|
|
a22040efa9 | ||
|
|
ba3da32ce6 | ||
|
|
9d2ba52160 | ||
|
|
e610506df4 | ||
|
|
dd91d28bfa | ||
|
|
1ebf7460fe | ||
|
|
8c930e5c33 | ||
|
|
4fb2f7474c | ||
|
|
5301f79989 | ||
|
|
6a7f7fdf15 | ||
|
|
11fb5f990e | ||
|
|
62f31ea24a | ||
|
|
f4ca5313e6 | ||
|
|
dfd90bd564 | ||
|
|
55508eb418 | ||
|
|
2a4fa4ed6f | ||
|
|
cf7c9f6372 | ||
|
|
ec68c3207d | ||
|
|
ecf140f3d6 | ||
|
|
13f73b7b87 | ||
|
|
12c5bcd24f | ||
|
|
74f0436ac7 | ||
|
|
22d31b1527 | ||
|
|
9f3bca771a | ||
|
|
4e16d90a81 | ||
|
|
d669d5c153 | ||
|
|
343d985e96 | ||
|
|
dc3958356d | ||
|
|
c4e85c3277 | ||
|
|
89e46376dc | ||
|
|
8eeb34dba8 | ||
|
|
7171305972 | ||
|
|
2b0c223425 | ||
|
|
8f960ab0f7 | ||
|
|
60350efa10 | ||
|
|
687f577562 | ||
|
|
8e59ce9e9f | ||
|
|
33d75354a2 | ||
|
|
0e4a65efb7 | ||
|
|
b88134cf04 | ||
|
|
2aaa212dbc | ||
|
|
1e37990938 | ||
|
|
a417c73bf7 | ||
|
|
37c34351ee | ||
|
|
8672232ee2 | ||
|
|
3ad10ff8d0 | ||
|
|
183643547b | ||
|
|
5568340b9a | ||
|
|
1399bd3065 | ||
|
|
9172e16e80 | ||
|
|
3e5f602396 | ||
|
|
379a3f27b8 | ||
|
|
ecec932a47 | ||
|
|
e239f69f69 | ||
|
|
034b089433 | ||
|
|
c0db96482c | ||
|
|
ffa8fa0a6f | ||
|
|
7e1d459a2d | ||
|
|
71c4fce87f | ||
|
|
e91da78ebb | ||
|
|
8adad6fa61 | ||
|
|
b47004bb7c | ||
|
|
08a7fb4de0 | ||
|
|
c17a9b11cc | ||
|
|
245a92a644 | ||
|
|
6b313946fe | ||
|
|
4586fb1d13 | ||
|
|
aa051434cb | ||
|
|
f3e1204fa1 | ||
|
|
1cb5d26344 | ||
|
|
ec9a2d8155 | ||
|
|
0227afffc8 | ||
|
|
6a421a1d96 | ||
|
|
4f55a0f1d0 | ||
|
|
3de55899fa | ||
|
|
ae4ad713ec | ||
|
|
21313adf9c | ||
|
|
9c1293ca45 | ||
|
|
1cb1e6b680 | ||
|
|
ed6ddeaa4c | ||
|
|
de08a89e6b | ||
|
|
dd42ef1920 | ||
|
|
dd192be689 | ||
|
|
52250ed10e | ||
|
|
4a26cd8d68 | ||
|
|
2ca972c3c8 | ||
|
|
74c0d55a6c | ||
|
|
3271e1464e | ||
|
|
cabd62b48f | ||
|
|
58c2355c8b | ||
|
|
bfe2065b9f | ||
|
|
9332b1355e | ||
|
|
679e703754 | ||
|
|
7322f90af4 | ||
|
|
e869df98c9 | ||
|
|
e499d36126 | ||
|
|
cac66d7fad | ||
|
|
320aaf0e33 | ||
|
|
178a175e99 | ||
|
|
5fdf1cb2d1 | ||
|
|
c64500dd85 | ||
|
|
812ad3f49e | ||
|
|
8e8a1a7541 | ||
|
|
4863b3df6e | ||
|
|
768c3a533b | ||
|
|
3dea554e9e | ||
|
|
16d4f6e4e1 | ||
|
|
9c7ecf221e | ||
|
|
26db481d46 | ||
|
|
3256a57230 | ||
|
|
cbc30587ff | ||
|
|
a27de38c03 | ||
|
|
e2f1609116 | ||
|
|
ea66a91a95 | ||
|
|
0d87c352b2 | ||
|
|
918f6ce0e6 | ||
|
|
6c5efe6ce0 | ||
|
|
f0be6675e7 | ||
|
|
6a8174a15c | ||
|
|
40c3f1b618 | ||
|
|
6dd2dac049 | ||
|
|
b39bbb557f | ||
|
|
f7682cba67 | ||
|
|
f94c07160a | ||
|
|
bbe6692580 | ||
|
|
9266a1c4d9 | ||
|
|
220d80f05f | ||
|
|
9144c909dd | ||
|
|
7981fcec84 | ||
|
|
71264c56fc | ||
|
|
ca0f77bdee | ||
|
|
fc8b1b8549 | ||
|
|
bc8c44f62f | ||
|
|
01fab5c92a | ||
|
|
1c07d786a0 | ||
|
|
6f0cd87d1c | ||
|
|
e44308cba2 | ||
|
|
50245c5157 | ||
|
|
9ca5188e12 | ||
|
|
56cc881ac0 | ||
|
|
50896bdc9d | ||
|
|
8dd4567828 | ||
|
|
06ef6d3e6a | ||
|
|
14b58e8062 | ||
|
|
eee232c12c | ||
|
|
febe321aef | ||
|
|
28777ac717 | ||
|
|
13b008b56c | ||
|
|
523efbd85a | ||
|
|
fcacc8bfc6 | ||
|
|
252b3c3bf6 | ||
|
|
24221748e1 | ||
|
|
141ae053db | ||
|
|
10ec4ff814 | ||
|
|
d2da0b7c0e | ||
|
|
7d0548406e | ||
|
|
c121dbbd67 | ||
|
|
c1c0a7d494 | ||
|
|
0749f60702 | ||
|
|
ca0ef18bdf | ||
|
|
6ed011e2f8 | ||
|
|
23d322452a | ||
|
|
5d3b965d28 | ||
|
|
d9794d72c7 | ||
|
|
524b5be937 | ||
|
|
ac2e276a6a | ||
|
|
4f4dbc0c22 | ||
|
|
d56e63a91b |
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.3.1'
|
||||
default: 'v0.3.2'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
5
.github/workflows/wpt.yml
vendored
5
.github/workflows/wpt.yml
vendored
@@ -5,6 +5,7 @@ env:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
on:
|
||||
@@ -73,7 +74,7 @@ jobs:
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-bench-hetzner
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 180
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
run: |
|
||||
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
||||
sleep 10s
|
||||
./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json
|
||||
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
|
||||
kill `cat WPT.pid`
|
||||
|
||||
- name: write commit
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM debian:stable-slim
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.0.365.4
|
||||
ARG ZIG_V8=v0.3.1
|
||||
ARG ZIG_V8=v0.3.2
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
@@ -36,10 +36,6 @@ RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
|
||||
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
|
||||
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
|
||||
|
||||
# install deps
|
||||
RUN git submodule init && \
|
||||
git submodule update --recursive
|
||||
|
||||
# download and install v8
|
||||
RUN case $TARGETPLATFORM in \
|
||||
"linux/arm64") ARCH="aarch64" ;; \
|
||||
|
||||
34
build.zig
34
build.zig
@@ -52,8 +52,19 @@ pub fn build(b: *Build) !void {
|
||||
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
|
||||
mod.addImport("build_config", opts.createModule());
|
||||
|
||||
// Format check
|
||||
const fmt_step = b.step("fmt", "Check code formatting");
|
||||
const fmt = b.addFmt(.{
|
||||
.paths = &.{ "src", "build.zig", "build.zig.zon" },
|
||||
.check = true,
|
||||
});
|
||||
fmt_step.dependOn(&fmt.step);
|
||||
|
||||
// Set default behavior
|
||||
b.default_step.dependOn(fmt_step);
|
||||
|
||||
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
|
||||
try linkCurl(b, mod);
|
||||
try linkCurl(b, mod, enable_tsan);
|
||||
try linkHtml5Ever(b, mod);
|
||||
|
||||
break :blk mod;
|
||||
@@ -189,19 +200,19 @@ fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void {
|
||||
mod.addObjectFile(obj);
|
||||
}
|
||||
|
||||
fn linkCurl(b: *Build, mod: *Build.Module) !void {
|
||||
fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
|
||||
const target = mod.resolved_target.?;
|
||||
|
||||
const curl = buildCurl(b, target, mod.optimize.?);
|
||||
const curl = buildCurl(b, target, mod.optimize.?, is_tsan);
|
||||
mod.linkLibrary(curl);
|
||||
|
||||
const zlib = buildZlib(b, target, mod.optimize.?);
|
||||
const zlib = buildZlib(b, target, mod.optimize.?, is_tsan);
|
||||
curl.root_module.linkLibrary(zlib);
|
||||
|
||||
const brotli = buildBrotli(b, target, mod.optimize.?);
|
||||
const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan);
|
||||
for (brotli) |lib| curl.root_module.linkLibrary(lib);
|
||||
|
||||
const nghttp2 = buildNghttp2(b, target, mod.optimize.?);
|
||||
const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan);
|
||||
curl.root_module.linkLibrary(nghttp2);
|
||||
|
||||
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
|
||||
@@ -218,13 +229,14 @@ fn linkCurl(b: *Build, mod: *Build.Module) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
|
||||
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
|
||||
const dep = b.dependency("zlib", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
|
||||
const lib = b.addLibrary(.{ .name = "z", .root_module = mod });
|
||||
@@ -249,13 +261,14 @@ fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.Opti
|
||||
return lib;
|
||||
}
|
||||
|
||||
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [3]*Build.Step.Compile {
|
||||
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile {
|
||||
const dep = b.dependency("brotli", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("c/include"));
|
||||
|
||||
@@ -311,13 +324,14 @@ fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin
|
||||
return .{ ssl, crypto };
|
||||
}
|
||||
|
||||
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *Build.Step.Compile {
|
||||
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
|
||||
const dep = b.dependency("nghttp2", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("lib/includes"));
|
||||
|
||||
@@ -362,6 +376,7 @@ fn buildCurl(
|
||||
b: *Build,
|
||||
target: Build.ResolvedTarget,
|
||||
optimize: std.builtin.OptimizeMode,
|
||||
is_tsan: bool,
|
||||
) *Build.Step.Compile {
|
||||
const dep = b.dependency("curl", .{});
|
||||
|
||||
@@ -369,6 +384,7 @@ fn buildCurl(
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("lib"));
|
||||
mod.addIncludePath(dep.path("include"));
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
.minimum_zig_version = "0.15.2",
|
||||
.dependencies = .{
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.1.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH64J7BAC81mkf6G9RbEJxS-W3TIRl5iFnShwbqCqy",
|
||||
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.2.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6wx-BABNgL7YIDgbnFgKZuXZ68yZNngNSrV6OjrY",
|
||||
},
|
||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
// v1.2.0
|
||||
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",
|
||||
|
||||
36
src/App.zig
36
src/App.zig
@@ -25,35 +25,38 @@ const Config = @import("Config.zig");
|
||||
const Snapshot = @import("browser/js/Snapshot.zig");
|
||||
const Platform = @import("browser/js/Platform.zig");
|
||||
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
||||
const RobotStore = @import("browser/Robots.zig").RobotStore;
|
||||
|
||||
pub const Http = @import("http/Http.zig");
|
||||
const Network = @import("network/Runtime.zig");
|
||||
pub const ArenaPool = @import("ArenaPool.zig");
|
||||
|
||||
const App = @This();
|
||||
|
||||
http: Http,
|
||||
network: Network,
|
||||
config: *const Config,
|
||||
platform: Platform,
|
||||
snapshot: Snapshot,
|
||||
telemetry: Telemetry,
|
||||
allocator: Allocator,
|
||||
arena_pool: ArenaPool,
|
||||
robots: RobotStore,
|
||||
app_dir_path: ?[]const u8,
|
||||
shutdown: bool = false,
|
||||
|
||||
pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||
const app = try allocator.create(App);
|
||||
errdefer allocator.destroy(app);
|
||||
|
||||
app.config = config;
|
||||
app.allocator = allocator;
|
||||
app.* = .{
|
||||
.config = config,
|
||||
.allocator = allocator,
|
||||
.network = undefined,
|
||||
.platform = undefined,
|
||||
.snapshot = undefined,
|
||||
.app_dir_path = undefined,
|
||||
.telemetry = undefined,
|
||||
.arena_pool = undefined,
|
||||
};
|
||||
|
||||
app.robots = RobotStore.init(allocator);
|
||||
|
||||
app.http = try Http.init(allocator, &app.robots, config);
|
||||
errdefer app.http.deinit();
|
||||
app.network = try Network.init(allocator, config);
|
||||
errdefer app.network.deinit();
|
||||
|
||||
app.platform = try Platform.init();
|
||||
errdefer app.platform.deinit();
|
||||
@@ -72,19 +75,18 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||
return app;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
|
||||
return;
|
||||
}
|
||||
pub fn shutdown(self: *const App) bool {
|
||||
return self.network.shutdown.load(.acquire);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
const allocator = self.allocator;
|
||||
if (self.app_dir_path) |app_dir_path| {
|
||||
allocator.free(app_dir_path);
|
||||
self.app_dir_path = null;
|
||||
}
|
||||
self.telemetry.deinit();
|
||||
self.robots.deinit();
|
||||
self.http.deinit();
|
||||
self.network.deinit();
|
||||
self.snapshot.deinit();
|
||||
self.platform.deinit();
|
||||
self.arena_pool.deinit();
|
||||
|
||||
@@ -31,6 +31,7 @@ pub const RunMode = enum {
|
||||
mcp,
|
||||
};
|
||||
|
||||
pub const MAX_LISTENERS = 16;
|
||||
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
|
||||
|
||||
// max message size
|
||||
@@ -153,6 +154,13 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cdpTimeout(self: *const Config) usize {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn maxConnections(self: *const Config) u16 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.cdp_max_connections,
|
||||
|
||||
@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Page = @import("browser/Page.zig");
|
||||
const Transfer = @import("http/Client.zig").Transfer;
|
||||
const Transfer = @import("browser/HttpClient.zig").Transfer;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
|
||||
118
src/Server.zig
118
src/Server.zig
@@ -18,8 +18,6 @@
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const net = std.net;
|
||||
const posix = std.posix;
|
||||
|
||||
@@ -30,16 +28,13 @@ const log = @import("log.zig");
|
||||
const App = @import("App.zig");
|
||||
const Config = @import("Config.zig");
|
||||
const CDP = @import("cdp/cdp.zig").CDP;
|
||||
const Net = @import("Net.zig");
|
||||
const Http = @import("http/Http.zig");
|
||||
const HttpClient = @import("http/Client.zig");
|
||||
const Net = @import("network/websocket.zig");
|
||||
const HttpClient = @import("browser/HttpClient.zig");
|
||||
|
||||
const Server = @This();
|
||||
|
||||
app: *App,
|
||||
shutdown: std.atomic.Value(bool) = .init(false),
|
||||
allocator: Allocator,
|
||||
listener: ?posix.socket_t,
|
||||
json_version_response: []const u8,
|
||||
|
||||
// Thread management
|
||||
@@ -48,103 +43,52 @@ clients: std.ArrayList(*Client) = .{},
|
||||
client_mutex: std.Thread.Mutex = .{},
|
||||
clients_pool: std.heap.MemoryPool(Client),
|
||||
|
||||
pub fn init(app: *App, address: net.Address) !Server {
|
||||
pub fn init(app: *App, address: net.Address) !*Server {
|
||||
const allocator = app.allocator;
|
||||
const json_version_response = try buildJSONVersionResponse(allocator, address);
|
||||
errdefer allocator.free(json_version_response);
|
||||
|
||||
return .{
|
||||
const self = try allocator.create(Server);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.app = app,
|
||||
.listener = null,
|
||||
.allocator = allocator,
|
||||
.json_version_response = json_version_response,
|
||||
.clients_pool = std.heap.MemoryPool(Client).init(app.allocator),
|
||||
.clients_pool = std.heap.MemoryPool(Client).init(allocator),
|
||||
};
|
||||
|
||||
try self.app.network.bind(address, self, onAccept);
|
||||
log.info(.app, "server running", .{ .address = address });
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Interrupts the server so that main can complete normally and call all defer handlers.
|
||||
pub fn stop(self: *Server) void {
|
||||
if (self.shutdown.swap(true, .release)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Shutdown all active clients
|
||||
pub fn deinit(self: *Server) void {
|
||||
// Stop all active clients
|
||||
{
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
|
||||
for (self.clients.items) |client| {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Linux and BSD/macOS handle canceling a socket blocked on accept differently.
|
||||
// For Linux, we use std.shutdown, which will cause accept to return error.SocketNotListening (EINVAL).
|
||||
// For BSD, shutdown will return an error. Instead we call posix.close, which will result with error.ConnectionAborted (BADF).
|
||||
if (self.listener) |listener| switch (builtin.target.os.tag) {
|
||||
.linux => posix.shutdown(listener, .recv) catch |err| {
|
||||
log.warn(.app, "listener shutdown", .{ .err = err });
|
||||
},
|
||||
.macos, .freebsd, .netbsd, .openbsd => {
|
||||
self.listener = null;
|
||||
posix.close(listener);
|
||||
},
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server) void {
|
||||
if (!self.shutdown.load(.acquire)) {
|
||||
self.stop();
|
||||
}
|
||||
|
||||
self.joinThreads();
|
||||
if (self.listener) |listener| {
|
||||
posix.close(listener);
|
||||
self.listener = null;
|
||||
}
|
||||
self.clients.deinit(self.allocator);
|
||||
self.clients_pool.deinit();
|
||||
self.allocator.free(self.json_version_response);
|
||||
self.allocator.destroy(self);
|
||||
}
|
||||
|
||||
pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
|
||||
const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK;
|
||||
const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
|
||||
self.listener = listener;
|
||||
|
||||
try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
|
||||
if (@hasDecl(posix.TCP, "NODELAY")) {
|
||||
try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
|
||||
}
|
||||
|
||||
try posix.bind(listener, &address.any, address.getOsSockLen());
|
||||
try posix.listen(listener, self.app.config.maxPendingConnections());
|
||||
|
||||
log.info(.app, "server running", .{ .address = address });
|
||||
while (!self.shutdown.load(.acquire)) {
|
||||
const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
|
||||
switch (err) {
|
||||
error.SocketNotListening, error.ConnectionAborted => {
|
||||
log.info(.app, "server stopped", .{});
|
||||
break;
|
||||
},
|
||||
error.WouldBlock => {
|
||||
std.Thread.sleep(10 * std.time.ns_per_ms);
|
||||
continue;
|
||||
},
|
||||
else => {
|
||||
log.err(.app, "CDP accept", .{ .err = err });
|
||||
std.Thread.sleep(std.time.ns_per_s);
|
||||
continue;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
self.spawnWorker(socket, timeout_ms) catch |err| {
|
||||
log.err(.app, "CDP spawn", .{ .err = err });
|
||||
posix.close(socket);
|
||||
};
|
||||
}
|
||||
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
|
||||
const self: *Server = @ptrCast(@alignCast(ctx));
|
||||
const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout());
|
||||
self.spawnWorker(socket, timeout_ms) catch |err| {
|
||||
log.err(.app, "CDP spawn", .{ .err = err });
|
||||
posix.close(socket);
|
||||
};
|
||||
}
|
||||
|
||||
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
|
||||
@@ -173,10 +117,10 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void
|
||||
self.registerClient(client);
|
||||
defer self.unregisterClient(client);
|
||||
|
||||
// Check shutdown after registering to avoid missing stop() signal.
|
||||
// If stop() already iterated over clients, this client won't receive stop()
|
||||
// Check shutdown after registering to avoid missing the stop signal.
|
||||
// If deinit() already iterated over clients, this client won't receive stop()
|
||||
// and would block joinThreads() indefinitely.
|
||||
if (self.shutdown.load(.acquire)) {
|
||||
if (self.app.shutdown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -213,7 +157,7 @@ fn unregisterClient(self: *Server, client: *Client) void {
|
||||
}
|
||||
|
||||
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
|
||||
if (self.shutdown.load(.acquire)) {
|
||||
if (self.app.shutdown()) {
|
||||
return error.ShuttingDown;
|
||||
}
|
||||
|
||||
@@ -283,7 +227,7 @@ pub const Client = struct {
|
||||
log.info(.app, "client connected", .{ .ip = client_address });
|
||||
}
|
||||
|
||||
const http = try app.http.createClient(allocator);
|
||||
const http = try HttpClient.init(allocator, &app.network);
|
||||
errdefer http.deinit();
|
||||
|
||||
return .{
|
||||
@@ -296,6 +240,10 @@ pub const Client = struct {
|
||||
}
|
||||
|
||||
fn stop(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.http => {},
|
||||
.cdp => |*cdp| cdp.browser.env.terminate(),
|
||||
}
|
||||
self.ws.shutdown();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const URL = @import("browser/URL.zig");
|
||||
|
||||
const TestHTTPServer = @This();
|
||||
|
||||
@@ -97,7 +98,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
|
||||
}
|
||||
|
||||
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
|
||||
var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) {
|
||||
var url_buf: [1024]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
|
||||
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
|
||||
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
|
||||
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
|
||||
else => return err,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const js = @import("js/js.zig");
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
const HttpClient = @import("../http/Client.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
|
||||
|
||||
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, self.page);
|
||||
defer event.deinit(false, self.page._session);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
|
||||
@@ -234,7 +234,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event,
|
||||
const page = self.page;
|
||||
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, page);
|
||||
defer event.deinit(false, page._session);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
|
||||
@@ -365,6 +365,29 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if there are any listeners for a direct dispatch (non-DOM target).
|
||||
/// Use this to avoid creating an event when there are no listeners.
|
||||
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
|
||||
if (hasHandler(handler)) {
|
||||
return true;
|
||||
}
|
||||
return self.lookup.get(.{
|
||||
.event_target = @intFromPtr(target),
|
||||
.type_string = .wrap(typ),
|
||||
}) != null;
|
||||
}
|
||||
|
||||
fn hasHandler(handler: anytype) bool {
|
||||
const ti = @typeInfo(@TypeOf(handler));
|
||||
if (ti == .null) {
|
||||
return false;
|
||||
}
|
||||
if (ti == .optional) {
|
||||
return handler != null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
@@ -377,12 +400,17 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
const page = self.page;
|
||||
var was_handled = false;
|
||||
|
||||
defer if (was_handled) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
ls.local.runMicrotasks();
|
||||
};
|
||||
// Create a single scope for all event handlers in this dispatch.
|
||||
// This ensures function handles passed to queueMicrotask remain valid
|
||||
// throughout the entire dispatch, preventing crashes when microtasks run.
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer {
|
||||
if (was_handled) {
|
||||
ls.local.runMicrotasks();
|
||||
}
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
const activation_state = ActivationState.create(event, target, page);
|
||||
|
||||
@@ -461,7 +489,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
.event_target = @intFromPtr(current_target),
|
||||
.type_string = event._type_string,
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(true, opts));
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,10 +504,6 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
was_handled = true;
|
||||
event._current_target = target_et;
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self.page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
|
||||
|
||||
if (event._stop_propagation) {
|
||||
@@ -495,7 +519,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
.type_string = event._type_string,
|
||||
.event_target = @intFromPtr(target_et),
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, comptime .init(null, opts));
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
}
|
||||
@@ -512,7 +536,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
.type_string = event._type_string,
|
||||
.event_target = @intFromPtr(current_target),
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(false, opts));
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,7 +554,7 @@ const DispatchPhaseOpts = struct {
|
||||
}
|
||||
};
|
||||
|
||||
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void {
|
||||
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void {
|
||||
const page = self.page;
|
||||
|
||||
// Track dispatch depth for deferred removal
|
||||
@@ -607,18 +631,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
||||
event._target = getAdjustedTarget(original_target, current_target);
|
||||
}
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
switch (listener.function) {
|
||||
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
|
||||
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
|
||||
.string => |string| {
|
||||
const str = try page.call_arena.dupeZ(u8, string.str());
|
||||
try ls.local.eval(str, null);
|
||||
try local.eval(str, null);
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = ls.toLocal(obj_global);
|
||||
const obj = local.toLocal(obj_global);
|
||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||
try handleEvent.callWithThis(void, obj, .{event});
|
||||
}
|
||||
|
||||
@@ -48,13 +48,11 @@ const Factory = @This();
|
||||
_arena: Allocator,
|
||||
_slab: SlabAllocator,
|
||||
|
||||
pub fn init(arena: Allocator) !*Factory {
|
||||
const self = try arena.create(Factory);
|
||||
self.* = .{
|
||||
pub fn init(arena: Allocator) Factory {
|
||||
return .{
|
||||
._arena = arena,
|
||||
._slab = SlabAllocator.init(arena, 128),
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
// this is a root object
|
||||
@@ -249,16 +247,15 @@ fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
|
||||
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
|
||||
// Special case: Blob has slice and mime fields, so we need manual setup
|
||||
const chain = try PrototypeChain(
|
||||
&.{ Blob, @TypeOf(child) },
|
||||
).allocate(allocator);
|
||||
).allocate(arena);
|
||||
|
||||
const blob_ptr = chain.get(0);
|
||||
blob_ptr.* = .{
|
||||
._arena = arena,
|
||||
._type = unionInit(Blob.Type, chain.get(1)),
|
||||
._slice = "",
|
||||
._mime = "",
|
||||
@@ -273,14 +270,16 @@ pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(chil
|
||||
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
|
||||
|
||||
const doc = page.document.asNode();
|
||||
chain.set(0, AbstractRange{
|
||||
const abstract_range = chain.get(0);
|
||||
abstract_range.* = AbstractRange{
|
||||
._type = unionInit(AbstractRange.Type, chain.get(1)),
|
||||
._end_offset = 0,
|
||||
._start_offset = 0,
|
||||
._end_container = doc,
|
||||
._start_container = doc,
|
||||
});
|
||||
};
|
||||
chain.setLeaf(1, child);
|
||||
page._live_ranges.append(&abstract_range._range_link);
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,28 +17,29 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const builtin = @import("builtin");
|
||||
const posix = std.posix;
|
||||
|
||||
const Net = @import("../Net.zig");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../log.zig");
|
||||
const Net = @import("../network/http.zig");
|
||||
const Network = @import("../network/Runtime.zig");
|
||||
const Config = @import("../Config.zig");
|
||||
const URL = @import("../browser/URL.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
|
||||
const Robots = @import("../browser/Robots.zig");
|
||||
const Robots = @import("../network/Robots.zig");
|
||||
const RobotStore = Robots.RobotStore;
|
||||
const posix = std.posix;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const Method = Net.Method;
|
||||
const ResponseHead = Net.ResponseHead;
|
||||
const HeaderIterator = Net.HeaderIterator;
|
||||
pub const Method = Net.Method;
|
||||
pub const Headers = Net.Headers;
|
||||
pub const ResponseHead = Net.ResponseHead;
|
||||
pub const HeaderIterator = Net.HeaderIterator;
|
||||
|
||||
// This is loosely tied to a browser Page. Loading all the <scripts>, doing
|
||||
// XHR requests, and loading imports all happens through here. Sine the app
|
||||
@@ -66,7 +67,7 @@ active: usize,
|
||||
intercepted: usize,
|
||||
|
||||
// Our easy handles, managed by a curl multi.
|
||||
handles: Handles,
|
||||
handles: Net.Handles,
|
||||
|
||||
// Use to generate the next request ID
|
||||
next_request_id: u32 = 0,
|
||||
@@ -77,8 +78,7 @@ queue: TransferQueue,
|
||||
// The main app allocator
|
||||
allocator: Allocator,
|
||||
|
||||
// Reference to the App-owned Robot Store.
|
||||
robot_store: *RobotStore,
|
||||
network: *Network,
|
||||
// Queue of requests that depend on a robots.txt.
|
||||
// Allows us to fetch the robots.txt just once.
|
||||
pending_robots_queue: std.StringHashMapUnmanaged(std.ArrayList(Request)) = .empty,
|
||||
@@ -97,8 +97,6 @@ http_proxy: ?[:0]const u8 = null,
|
||||
// CDP.
|
||||
use_proxy: bool,
|
||||
|
||||
config: *const Config,
|
||||
|
||||
cdp_client: ?CDPClient = null,
|
||||
|
||||
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
|
||||
@@ -121,14 +119,14 @@ pub const CDPClient = struct {
|
||||
|
||||
const TransferQueue = std.DoublyLinkedList;
|
||||
|
||||
pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore, config: *const Config) !*Client {
|
||||
pub fn init(allocator: Allocator, network: *Network) !*Client {
|
||||
var transfer_pool = std.heap.MemoryPool(Transfer).init(allocator);
|
||||
errdefer transfer_pool.deinit();
|
||||
|
||||
const client = try allocator.create(Client);
|
||||
errdefer allocator.destroy(client);
|
||||
|
||||
var handles = try Handles.init(allocator, ca_blob, config);
|
||||
var handles = try Net.Handles.init(allocator, network.ca_blob, network.config);
|
||||
errdefer handles.deinit(allocator);
|
||||
|
||||
// Set transfer callbacks on each connection.
|
||||
@@ -136,7 +134,7 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore,
|
||||
try conn.setCallbacks(Transfer.headerCallback, Transfer.dataCallback);
|
||||
}
|
||||
|
||||
const http_proxy = config.httpProxy();
|
||||
const http_proxy = network.config.httpProxy();
|
||||
|
||||
client.* = .{
|
||||
.queue = .{},
|
||||
@@ -144,10 +142,9 @@ pub fn init(allocator: Allocator, ca_blob: ?Net.Blob, robot_store: *RobotStore,
|
||||
.intercepted = 0,
|
||||
.handles = handles,
|
||||
.allocator = allocator,
|
||||
.robot_store = robot_store,
|
||||
.network = network,
|
||||
.http_proxy = http_proxy,
|
||||
.use_proxy = http_proxy != null,
|
||||
.config = config,
|
||||
.transfer_pool = transfer_pool,
|
||||
};
|
||||
|
||||
@@ -170,7 +167,7 @@ pub fn deinit(self: *Client) void {
|
||||
}
|
||||
|
||||
pub fn newHeaders(self: *const Client) !Net.Headers {
|
||||
return Net.Headers.init(self.config.http_headers.user_agent_header);
|
||||
return Net.Headers.init(self.network.config.http_headers.user_agent_header);
|
||||
}
|
||||
|
||||
pub fn abort(self: *Client) void {
|
||||
@@ -191,6 +188,8 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
|
||||
n = node.next;
|
||||
const conn: *Net.Connection = @fieldParentPtr("node", node);
|
||||
var transfer = Transfer.fromConnection(conn) catch |err| {
|
||||
// Let's cleanup what we can
|
||||
self.handles.remove(conn);
|
||||
log.err(.http, "get private info", .{ .err = err, .source = "abort" });
|
||||
continue;
|
||||
};
|
||||
@@ -253,12 +252,12 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus {
|
||||
}
|
||||
|
||||
pub fn request(self: *Client, req: Request) !void {
|
||||
if (self.config.obeyRobots()) {
|
||||
if (self.network.config.obeyRobots()) {
|
||||
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
|
||||
errdefer self.allocator.free(robots_url);
|
||||
|
||||
// If we have this robots cached, we can take a fast path.
|
||||
if (self.robot_store.get(robots_url)) |robot_entry| {
|
||||
if (self.network.robot_store.get(robots_url)) |robot_entry| {
|
||||
defer self.allocator.free(robots_url);
|
||||
|
||||
switch (robot_entry) {
|
||||
@@ -399,18 +398,18 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void {
|
||||
switch (ctx.status) {
|
||||
200 => {
|
||||
if (ctx.buffer.items.len > 0) {
|
||||
const robots: ?Robots = ctx.client.robot_store.robotsFromBytes(
|
||||
ctx.client.config.http_headers.user_agent,
|
||||
const robots: ?Robots = ctx.client.network.robot_store.robotsFromBytes(
|
||||
ctx.client.network.config.http_headers.user_agent,
|
||||
ctx.buffer.items,
|
||||
) catch blk: {
|
||||
log.warn(.browser, "failed to parse robots", .{ .robots_url = ctx.robots_url });
|
||||
// If we fail to parse, we just insert it as absent and ignore.
|
||||
try ctx.client.robot_store.putAbsent(ctx.robots_url);
|
||||
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
|
||||
break :blk null;
|
||||
};
|
||||
|
||||
if (robots) |r| {
|
||||
try ctx.client.robot_store.put(ctx.robots_url, r);
|
||||
try ctx.client.network.robot_store.put(ctx.robots_url, r);
|
||||
const path = URL.getPathname(ctx.req.url);
|
||||
allowed = r.isAllowed(path);
|
||||
}
|
||||
@@ -419,12 +418,12 @@ fn robotsDoneCallback(ctx_ptr: *anyopaque) !void {
|
||||
404 => {
|
||||
log.debug(.http, "robots not found", .{ .url = ctx.robots_url });
|
||||
// If we get a 404, we just insert it as absent.
|
||||
try ctx.client.robot_store.putAbsent(ctx.robots_url);
|
||||
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
|
||||
},
|
||||
else => {
|
||||
log.debug(.http, "unexpected status on robots", .{ .url = ctx.robots_url, .status = ctx.status });
|
||||
// If we get an unexpected status, we just insert as absent.
|
||||
try ctx.client.robot_store.putAbsent(ctx.robots_url);
|
||||
try ctx.client.network.robot_store.putAbsent(ctx.robots_url);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -607,7 +606,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
|
||||
.req = req,
|
||||
.ctx = req.ctx,
|
||||
.client = self,
|
||||
.max_response_size = self.config.httpMaxResponseSize(),
|
||||
.max_response_size = self.network.config.httpMaxResponseSize(),
|
||||
};
|
||||
return transfer;
|
||||
}
|
||||
@@ -665,7 +664,7 @@ pub fn restoreOriginalProxy(self: *Client) !void {
|
||||
}
|
||||
|
||||
// Enable TLS verification on all connections.
|
||||
pub fn enableTlsVerify(self: *const Client) !void {
|
||||
pub fn enableTlsVerify(self: *Client) !void {
|
||||
// Remove inflight connections check on enable TLS b/c chromiumoxide calls
|
||||
// the command during navigate and Curl seems to accept it...
|
||||
|
||||
@@ -675,7 +674,7 @@ pub fn enableTlsVerify(self: *const Client) !void {
|
||||
}
|
||||
|
||||
// Disable TLS verification on all connections.
|
||||
pub fn disableTlsVerify(self: *const Client) !void {
|
||||
pub fn disableTlsVerify(self: *Client) !void {
|
||||
// Remove inflight connections check on disable TLS b/c chromiumoxide calls
|
||||
// the command during navigate and Curl seems to accept it...
|
||||
|
||||
@@ -689,7 +688,11 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
|
||||
|
||||
{
|
||||
transfer._conn = conn;
|
||||
errdefer transfer.deinit();
|
||||
errdefer {
|
||||
transfer._conn = null;
|
||||
transfer.deinit();
|
||||
self.handles.isAvailable(conn);
|
||||
}
|
||||
|
||||
try conn.setURL(req.url);
|
||||
try conn.setMethod(req.method);
|
||||
@@ -700,7 +703,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
|
||||
}
|
||||
|
||||
var header_list = req.headers;
|
||||
try conn.secretHeaders(&header_list, &self.config.http_headers); // Add headers that must be hidden from intercepts
|
||||
try conn.secretHeaders(&header_list, &self.network.config.http_headers); // Add headers that must be hidden from intercepts
|
||||
try conn.setHeaders(&header_list);
|
||||
|
||||
// Add cookies.
|
||||
@@ -712,21 +715,28 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
|
||||
|
||||
// add credentials
|
||||
if (req.credentials) |creds| {
|
||||
try conn.setProxyCredentials(creds);
|
||||
if (transfer._auth_challenge != null and transfer._auth_challenge.?.source == .proxy) {
|
||||
try conn.setProxyCredentials(creds);
|
||||
} else {
|
||||
try conn.setCredentials(creds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once soon as this is called, our "perform" loop is responsible for
|
||||
// As soon as this is called, our "perform" loop is responsible for
|
||||
// cleaning things up. That's why the above code is in a block. If anything
|
||||
// fails BEFORE `curl_multi_add_handle` suceeds, the we still need to do
|
||||
// fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do
|
||||
// cleanup. But if things fail after `curl_multi_add_handle`, we expect
|
||||
// perfom to pickup the failure and cleanup.
|
||||
try self.handles.add(conn);
|
||||
self.handles.add(conn) catch |err| {
|
||||
transfer._conn = null;
|
||||
transfer.deinit();
|
||||
self.handles.isAvailable(conn);
|
||||
return err;
|
||||
};
|
||||
|
||||
if (req.start_callback) |cb| {
|
||||
cb(transfer) catch |err| {
|
||||
self.handles.remove(conn);
|
||||
transfer._conn = null;
|
||||
transfer.deinit();
|
||||
return err;
|
||||
};
|
||||
@@ -834,7 +844,7 @@ fn processMessages(self: *Client) !bool {
|
||||
// In case of request w/o data, we need to call the header done
|
||||
// callback now.
|
||||
const proceed = transfer.headerDoneCallback(&msg.conn) catch |err| {
|
||||
log.err(.http, "header_done_callback", .{ .err = err });
|
||||
log.err(.http, "header_done_callback2", .{ .err = err });
|
||||
requestFailed(transfer, err, true);
|
||||
continue;
|
||||
};
|
||||
@@ -872,8 +882,6 @@ fn ensureNoActiveConnection(self: *const Client) !void {
|
||||
}
|
||||
}
|
||||
|
||||
const Handles = Net.Handles;
|
||||
|
||||
pub const RequestCookie = struct {
|
||||
is_http: bool,
|
||||
jar: *CookieJar,
|
||||
@@ -1300,9 +1308,9 @@ pub const Transfer = struct {
|
||||
// WWW-Authenticate or Proxy-Authenticate header.
|
||||
transfer._auth_challenge = .{
|
||||
.status = status,
|
||||
.source = undefined,
|
||||
.scheme = undefined,
|
||||
.realm = undefined,
|
||||
.source = null,
|
||||
.scheme = null,
|
||||
.realm = null,
|
||||
};
|
||||
return buf_len;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const Performance = @import("webapi/Performance.zig");
|
||||
const Screen = @import("webapi/Screen.zig");
|
||||
const VisualViewport = @import("webapi/VisualViewport.zig");
|
||||
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
|
||||
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const MutationObserver = @import("webapi/MutationObserver.zig");
|
||||
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
|
||||
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
||||
@@ -62,13 +63,13 @@ const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
|
||||
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
|
||||
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
|
||||
|
||||
const Http = App.Http;
|
||||
const Net = @import("../Net.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const timestamp = @import("../datetime.zig").timestamp;
|
||||
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
|
||||
|
||||
const IFrame = Element.Html.IFrame;
|
||||
const WebApiURL = @import("webapi/URL.zig");
|
||||
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
|
||||
|
||||
@@ -142,6 +143,9 @@ _to_load: std.ArrayList(*Element.Html) = .{},
|
||||
|
||||
_script_manager: ScriptManager,
|
||||
|
||||
// List of active live ranges (for mutation updates per DOM spec)
|
||||
_live_ranges: std.DoublyLinkedList = .{},
|
||||
|
||||
// List of active MutationObservers
|
||||
_mutation_observers: std.DoublyLinkedList = .{},
|
||||
_mutation_delivery_scheduled: bool = false,
|
||||
@@ -190,6 +194,8 @@ _queued_navigation: ?*QueuedNavigation = null,
|
||||
// The URL of the current page
|
||||
url: [:0]const u8 = "about:blank",
|
||||
|
||||
origin: ?[]const u8 = null,
|
||||
|
||||
// The base url specifies the base URL used to resolve the relative urls.
|
||||
// It is set by a <base> tag.
|
||||
// If null the url must be used.
|
||||
@@ -212,18 +218,10 @@ arena: Allocator,
|
||||
// from JS. Best arena to use, when possible.
|
||||
call_arena: Allocator,
|
||||
|
||||
arena_pool: *ArenaPool,
|
||||
// In Debug, we use this to see if anything fails to release an arena back to
|
||||
// the pool.
|
||||
_arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
|
||||
owner: []const u8,
|
||||
count: usize,
|
||||
}) else void) = if (IS_DEBUG) .empty else {},
|
||||
|
||||
parent: ?*Page,
|
||||
window: *Window,
|
||||
document: *Document,
|
||||
iframe: ?*Element.Html.IFrame = null,
|
||||
iframe: ?*IFrame = null,
|
||||
frames: std.ArrayList(*Page) = .{},
|
||||
frames_sorted: bool = true,
|
||||
|
||||
@@ -236,7 +234,7 @@ version: usize = 0,
|
||||
// ScriptManager, so all scripts just count as 1 pending load.
|
||||
_pending_loads: u32,
|
||||
|
||||
_parent_notified: if (IS_DEBUG) bool else void = if (IS_DEBUG) false else {},
|
||||
_parent_notified: bool = false,
|
||||
|
||||
_type: enum { root, frame }, // only used for logs right now
|
||||
_req_id: u32 = 0,
|
||||
@@ -246,17 +244,11 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "page.init", .{});
|
||||
}
|
||||
const browser = session.browser;
|
||||
const arena_pool = browser.arena_pool;
|
||||
|
||||
const page_arena = if (parent) |p| p.arena else try arena_pool.acquire();
|
||||
errdefer if (parent == null) arena_pool.release(page_arena);
|
||||
|
||||
var factory = if (parent) |p| p._factory else try Factory.init(page_arena);
|
||||
|
||||
const call_arena = try arena_pool.acquire();
|
||||
errdefer arena_pool.release(call_arena);
|
||||
const call_arena = try session.getArena(.{ .debug = "call_arena" });
|
||||
errdefer session.releaseArena(call_arena);
|
||||
|
||||
const factory = &session.factory;
|
||||
const document = (try factory.document(Node.Document.HTMLDocument{
|
||||
._proto = undefined,
|
||||
})).asDocument();
|
||||
@@ -264,10 +256,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
self.* = .{
|
||||
.js = undefined,
|
||||
.parent = parent,
|
||||
.arena = page_arena,
|
||||
.arena = session.page_arena,
|
||||
.document = document,
|
||||
.window = undefined,
|
||||
.arena_pool = arena_pool,
|
||||
.call_arena = call_arena,
|
||||
._frame_id = frame_id,
|
||||
._session = session,
|
||||
@@ -275,7 +266,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
._pending_loads = 1, // always 1 for the ScriptManager
|
||||
._type = if (parent == null) .root else .frame,
|
||||
._script_manager = undefined,
|
||||
._event_manager = EventManager.init(page_arena, self),
|
||||
._event_manager = EventManager.init(session.page_arena, self),
|
||||
};
|
||||
|
||||
var screen: *Screen = undefined;
|
||||
@@ -303,6 +294,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
._visual_viewport = visual_viewport,
|
||||
});
|
||||
|
||||
const browser = session.browser;
|
||||
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
|
||||
errdefer self._script_manager.deinit();
|
||||
|
||||
@@ -323,9 +315,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Page) void {
|
||||
pub fn deinit(self: *Page, abort_http: bool) void {
|
||||
for (self.frames.items) |frame| {
|
||||
frame.deinit();
|
||||
frame.deinit(abort_http);
|
||||
}
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
@@ -338,31 +330,28 @@ pub fn deinit(self: *Page) void {
|
||||
// stats.print(&stream) catch unreachable;
|
||||
}
|
||||
|
||||
const session = self._session;
|
||||
|
||||
if (self._queued_navigation) |qn| {
|
||||
self.arena_pool.release(qn.arena);
|
||||
session.releaseArena(qn.arena);
|
||||
}
|
||||
|
||||
const session = self._session;
|
||||
session.browser.env.destroyContext(self.js);
|
||||
|
||||
self._script_manager.shutdown = true;
|
||||
session.browser.http_client.abort();
|
||||
self._script_manager.deinit();
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
var it = self._arena_pool_leak_track.valueIterator();
|
||||
while (it.next()) |value_ptr| {
|
||||
if (value_ptr.count > 0) {
|
||||
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type, .url = self.url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.arena_pool.release(self.call_arena);
|
||||
|
||||
if (self.parent == null) {
|
||||
self.arena_pool.release(self.arena);
|
||||
session.browser.http_client.abort();
|
||||
} else if (abort_http) {
|
||||
// a small optimization, it's faster to abort _everything_ on the root
|
||||
// page, so we prefer that. But if it's just the frame that's going
|
||||
// away (a frame navigation) then we'll abort the frame-related requests
|
||||
session.browser.http_client.abortFrame(self._frame_id);
|
||||
}
|
||||
|
||||
self._script_manager.deinit();
|
||||
|
||||
session.releaseArena(self.call_arena);
|
||||
}
|
||||
|
||||
pub fn base(self: *const Page) [:0]const u8 {
|
||||
@@ -376,14 +365,10 @@ pub fn getTitle(self: *Page) !?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getOrigin(self: *Page, allocator: Allocator) !?[]const u8 {
|
||||
return try URL.getOrigin(allocator, self.url);
|
||||
}
|
||||
|
||||
// Add comon headers for a request:
|
||||
// * cookies
|
||||
// * referer
|
||||
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *Http.Headers) !void {
|
||||
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void {
|
||||
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
|
||||
|
||||
// Build the referer
|
||||
@@ -406,35 +391,16 @@ pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, header
|
||||
}
|
||||
}
|
||||
|
||||
const GetArenaOpts = struct {
|
||||
debug: []const u8,
|
||||
};
|
||||
pub fn getArena(self: *Page, comptime opts: GetArenaOpts) !Allocator {
|
||||
const allocator = try self.arena_pool.acquire();
|
||||
if (comptime IS_DEBUG) {
|
||||
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
|
||||
if (gop.found_existing) {
|
||||
std.debug.assert(gop.value_ptr.count == 0);
|
||||
}
|
||||
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
|
||||
}
|
||||
return allocator;
|
||||
pub fn getArena(self: *Page, comptime opts: Session.GetArenaOpts) !Allocator {
|
||||
return self._session.getArena(opts);
|
||||
}
|
||||
|
||||
pub fn releaseArena(self: *Page, allocator: Allocator) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
|
||||
if (found.count != 1) {
|
||||
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type, .url = self.url });
|
||||
return;
|
||||
}
|
||||
found.count = 0;
|
||||
}
|
||||
return self.arena_pool.release(allocator);
|
||||
return self._session.releaseArena(allocator);
|
||||
}
|
||||
|
||||
pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
|
||||
const current_origin = (try URL.getOrigin(self.call_arena, self.url)) orelse return false;
|
||||
const current_origin = self.origin orelse return false;
|
||||
return std.mem.startsWith(u8, url, current_origin);
|
||||
}
|
||||
|
||||
@@ -456,12 +422,23 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
// if the url is about:blank, we load an empty HTML document in the
|
||||
// page and dispatch the events.
|
||||
if (std.mem.eql(u8, "about:blank", request_url)) {
|
||||
self.url = "about:blank";
|
||||
|
||||
if (self.parent) |parent| {
|
||||
self.origin = parent.origin;
|
||||
} else {
|
||||
self.origin = null;
|
||||
}
|
||||
try self.js.setOrigin(self.origin);
|
||||
|
||||
// Assume we parsed the document.
|
||||
// It's important to force a reset during the following navigation.
|
||||
self._parse_state = .complete;
|
||||
|
||||
// We do not processHTMLDoc here as we know we don't have any scripts
|
||||
// This assumption may be false when CDP Page.addScriptToEvaluateOnNewDocument is implemented
|
||||
self.document.injectBlank(self) catch |err| {
|
||||
log.err(.browser, "inject blank", .{ .err = err });
|
||||
return error.InjectBlankFailed;
|
||||
};
|
||||
self.documentIsComplete();
|
||||
|
||||
session.notification.dispatch(.page_navigate, &.{
|
||||
@@ -500,6 +477,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
var http_client = session.browser.http_client;
|
||||
|
||||
self.url = try self.arena.dupeZ(u8, request_url);
|
||||
self.origin = try URL.getOrigin(self.arena, self.url);
|
||||
|
||||
self._req_id = req_id;
|
||||
self._navigated_options = .{
|
||||
@@ -552,57 +530,92 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
};
|
||||
}
|
||||
|
||||
// We cannot navigate immediately as navigating will delete the DOM tree,
|
||||
// which holds this event's node.
|
||||
// As such we schedule the function to be called as soon as possible.
|
||||
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
|
||||
if (self.canScheduleNavigation(priority) == false) {
|
||||
// Navigation can happen in many places, such as executing a <script> tag or
|
||||
// a JavaScript callback, a CDP command, etc...It's rarely safe to do immediately
|
||||
// as the caller almost certainly does'nt expect the page to go away during the
|
||||
// call. So, we schedule the navigation for the next tick.
|
||||
pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
|
||||
if (self.canScheduleNavigation(std.meta.activeTag(nt)) == false) {
|
||||
return;
|
||||
}
|
||||
const arena = try self.arena_pool.acquire();
|
||||
errdefer self.arena_pool.release(arena);
|
||||
return self.scheduleNavigationWithArena(arena, request_url, opts, priority);
|
||||
const arena = try self._session.getArena(.{ .debug = "scheduleNavigation" });
|
||||
errdefer self._session.releaseArena(arena);
|
||||
return self.scheduleNavigationWithArena(arena, request_url, opts, nt);
|
||||
}
|
||||
|
||||
fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void {
|
||||
const resolved_url = try URL.resolve(
|
||||
arena,
|
||||
self.base(),
|
||||
request_url,
|
||||
.{ .always_dupe = true, .encode = true },
|
||||
);
|
||||
// Don't name the first parameter "self", because the target of this navigation
|
||||
// might change inside the function. So the code should be explicit about the
|
||||
// page that it's acting on.
|
||||
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
|
||||
const resolved_url, const is_about_blank = blk: {
|
||||
if (std.mem.eql(u8, request_url, "about:blank")) {
|
||||
// navigate will handle this special case
|
||||
break :blk .{ "about:blank", true };
|
||||
}
|
||||
const u = try URL.resolve(
|
||||
arena,
|
||||
originator.base(),
|
||||
request_url,
|
||||
.{ .always_dupe = true, .encode = true },
|
||||
);
|
||||
break :blk .{ u, false };
|
||||
};
|
||||
|
||||
const session = self._session;
|
||||
if (!opts.force and URL.eqlDocument(self.url, resolved_url)) {
|
||||
self.arena_pool.release(arena);
|
||||
const target = switch (nt) {
|
||||
.script => |p| p orelse originator,
|
||||
.iframe => |iframe| iframe._window.?._page, // only an frame with existing content (i.e. a window) can be navigated
|
||||
.anchor, .form => |node| blk: {
|
||||
const doc = node.ownerDocument(originator) orelse break :blk originator;
|
||||
break :blk doc._page orelse originator;
|
||||
},
|
||||
};
|
||||
|
||||
self.url = try self.arena.dupeZ(u8, resolved_url);
|
||||
self.window._location = try Location.init(self.url, self);
|
||||
self.document._location = self.window._location;
|
||||
return session.navigation.updateEntries(self.url, opts.kind, self, true);
|
||||
const session = target._session;
|
||||
if (!opts.force and URL.eqlDocument(target.url, resolved_url)) {
|
||||
target.url = try target.arena.dupeZ(u8, resolved_url);
|
||||
target.window._location = try Location.init(target.url, target);
|
||||
target.document._location = target.window._location;
|
||||
if (target.parent == null) {
|
||||
try session.navigation.updateEntries(target.url, opts.kind, target, true);
|
||||
}
|
||||
// don't defer this, the caller is responsible for freeing it on error
|
||||
session.releaseArena(arena);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(.browser, "schedule navigation", .{
|
||||
.url = resolved_url,
|
||||
.reason = opts.reason,
|
||||
.target = resolved_url,
|
||||
.type = self._type,
|
||||
.type = target._type,
|
||||
});
|
||||
|
||||
session.browser.http_client.abort();
|
||||
// This is a micro-optimization. Terminate any inflight request as early
|
||||
// as we can. This will be more propery shutdown when we process the
|
||||
// scheduled navigation.
|
||||
if (target.parent == null) {
|
||||
session.browser.http_client.abort();
|
||||
} else {
|
||||
// This doesn't terminate any inflight requests for nested frames, but
|
||||
// again, this is just an optimization. We'll correctly shut down all
|
||||
// nested inflight requests when we process the navigation.
|
||||
session.browser.http_client.abortFrame(target._frame_id);
|
||||
}
|
||||
|
||||
const qn = try arena.create(QueuedNavigation);
|
||||
qn.* = .{
|
||||
.opts = opts,
|
||||
.arena = arena,
|
||||
.url = resolved_url,
|
||||
.priority = priority,
|
||||
.is_about_blank = is_about_blank,
|
||||
.navigation_type = std.meta.activeTag(nt),
|
||||
};
|
||||
|
||||
if (self._queued_navigation) |existing| {
|
||||
self.arena_pool.release(existing.arena);
|
||||
if (target._queued_navigation) |existing| {
|
||||
session.releaseArena(existing.arena);
|
||||
}
|
||||
self._queued_navigation = qn;
|
||||
|
||||
target._queued_navigation = qn;
|
||||
return session.scheduleNavigation(target);
|
||||
}
|
||||
|
||||
// A script can have multiple competing navigation events, say it starts off
|
||||
@@ -610,23 +623,31 @@ fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []con
|
||||
// You might think that we just stop at the first one, but that doesn't seem
|
||||
// to be what browsers do, and it isn't particularly well supported by v8 (i.e.
|
||||
// halting execution mid-script).
|
||||
// From what I can tell, there are 3 "levels" of priority, in order:
|
||||
// From what I can tell, there are 4 "levels" of priority, in order:
|
||||
// 1 - form submission
|
||||
// 2 - JavaScript apis (e.g. top.location)
|
||||
// 3 - anchor clicks
|
||||
// 4 - iframe.src =
|
||||
// Within, each category, it's last-one-wins.
|
||||
fn canScheduleNavigation(self: *Page, priority: NavigationPriority) bool {
|
||||
const existing = self._queued_navigation orelse return true;
|
||||
fn canScheduleNavigation(self: *Page, new_target_type: NavigationType) bool {
|
||||
if (self.parent) |parent| {
|
||||
if (parent.isGoingAway()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.priority == priority) {
|
||||
const existing_target_type = (self._queued_navigation orelse return true).navigation_type;
|
||||
|
||||
if (existing_target_type == new_target_type) {
|
||||
// same reason, than this latest one wins
|
||||
return true;
|
||||
}
|
||||
|
||||
return switch (existing.priority) {
|
||||
.anchor => true, // everything is higher priority than an anchor
|
||||
return switch (existing_target_type) {
|
||||
.iframe => true, // everything is higher priority than iframe.src = "x"
|
||||
.anchor => new_target_type != .iframe, // an anchor is only higher priority than an iframe
|
||||
.form => false, // nothing is higher priority than a form
|
||||
.script => priority == .form, // a form is higher priority than a script
|
||||
.script => new_target_type == .form, // a form is higher priority than a script
|
||||
};
|
||||
}
|
||||
|
||||
@@ -658,7 +679,7 @@ pub fn scriptsCompletedLoading(self: *Page) void {
|
||||
self.pendingLoadCompleted();
|
||||
}
|
||||
|
||||
pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void {
|
||||
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
|
||||
blk: {
|
||||
var ls: JS.Local.Scope = undefined;
|
||||
self.js.localScope(&ls);
|
||||
@@ -707,17 +728,18 @@ pub fn documentIsComplete(self: *Page) void {
|
||||
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
|
||||
};
|
||||
|
||||
if (IS_DEBUG) {
|
||||
std.debug.assert(self._navigated_options != null);
|
||||
if (self._navigated_options) |no| {
|
||||
// _navigated_options will be null in special short-circuit cases, like
|
||||
// "navigating" to about:blank, in which case this notification has
|
||||
// already been sent
|
||||
self._session.notification.dispatch(.page_navigated, &.{
|
||||
.frame_id = self._frame_id,
|
||||
.req_id = self._req_id,
|
||||
.opts = no,
|
||||
.url = self.url,
|
||||
.timestamp = timestamp(.monotonic),
|
||||
});
|
||||
}
|
||||
|
||||
self._session.notification.dispatch(.page_navigated, &.{
|
||||
.frame_id = self._frame_id,
|
||||
.req_id = self._req_id,
|
||||
.opts = self._navigated_options.?,
|
||||
.url = self.url,
|
||||
.timestamp = timestamp(.monotonic),
|
||||
});
|
||||
}
|
||||
|
||||
fn _documentIsComplete(self: *Page) !void {
|
||||
@@ -727,45 +749,50 @@ fn _documentIsComplete(self: *Page) !void {
|
||||
try self.dispatchLoad();
|
||||
|
||||
// Dispatch window.load event.
|
||||
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
|
||||
// This event is weird, it's dispatched directly on the window, but
|
||||
// with the document as the target.
|
||||
event._target = self.document.asEventTarget();
|
||||
try self._event_manager.dispatchDirect(
|
||||
self.window.asEventTarget(),
|
||||
event,
|
||||
self.window._on_load,
|
||||
.{ .inject_target = false, .context = "page load" },
|
||||
);
|
||||
const window_target = self.window.asEventTarget();
|
||||
if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) {
|
||||
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
|
||||
// This event is weird, it's dispatched directly on the window, but
|
||||
// with the document as the target.
|
||||
event._target = self.document.asEventTarget();
|
||||
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
|
||||
}
|
||||
|
||||
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
|
||||
try self._event_manager.dispatchDirect(
|
||||
self.window.asEventTarget(),
|
||||
pageshow_event,
|
||||
self.window._on_pageshow,
|
||||
.{ .context = "page show" },
|
||||
);
|
||||
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
|
||||
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
|
||||
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
|
||||
}
|
||||
|
||||
self.notifyParentLoadComplete();
|
||||
}
|
||||
|
||||
fn notifyParentLoadComplete(self: *Page) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(self._parent_notified == false);
|
||||
self._parent_notified = true;
|
||||
const parent = self.parent orelse return;
|
||||
|
||||
if (self._parent_notified == true) {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(false);
|
||||
}
|
||||
// shouldn't happen, don't want to crash a release build over it
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.parent) |p| {
|
||||
p.iframeCompletedLoading(self.iframe.?);
|
||||
}
|
||||
self._parent_notified = true;
|
||||
parent.iframeCompletedLoading(self.iframe.?);
|
||||
}
|
||||
|
||||
fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool {
|
||||
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
// would be different than self.url in the case of a redirect
|
||||
const header = &transfer.response_header.?;
|
||||
self.url = try self.arena.dupeZ(u8, std.mem.span(header.url));
|
||||
|
||||
const response_url = std.mem.span(header.url);
|
||||
if (std.mem.eql(u8, response_url, self.url) == false) {
|
||||
// would be different than self.url in the case of a redirect
|
||||
self.url = try self.arena.dupeZ(u8, response_url);
|
||||
self.origin = try URL.getOrigin(self.arena, self.url);
|
||||
}
|
||||
try self.js.setOrigin(self.origin);
|
||||
|
||||
self.window._location = try Location.init(self.url, self);
|
||||
self.document._location = self.window._location;
|
||||
@@ -782,7 +809,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
|
||||
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
if (self._parse_state == .pre) {
|
||||
@@ -796,7 +823,12 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
|
||||
} orelse .unknown;
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type, .url = self.url });
|
||||
log.debug(.page, "navigate first chunk", .{
|
||||
.content_type = mime.content_type,
|
||||
.len = data.len,
|
||||
.type = self._type,
|
||||
.url = self.url,
|
||||
});
|
||||
}
|
||||
|
||||
switch (mime.content_type) {
|
||||
@@ -850,7 +882,11 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
|
||||
try self._session.navigation.commitNavigation(self);
|
||||
|
||||
defer if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "page.load.complete", .{ .url = self.url, .type = self._type });
|
||||
log.debug(.page, "page load complete", .{
|
||||
.url = self.url,
|
||||
.type = self._type,
|
||||
.state = std.meta.activeTag(self._parse_state),
|
||||
});
|
||||
};
|
||||
|
||||
const parse_arena = try self.getArena(.{ .debug = "Page.parse" });
|
||||
@@ -928,7 +964,11 @@ fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
}
|
||||
|
||||
pub fn isGoingAway(self: *const Page) bool {
|
||||
return self._queued_navigation != null;
|
||||
if (self._queued_navigation != null) {
|
||||
return true;
|
||||
}
|
||||
const parent = self.parent orelse return false;
|
||||
return parent.isGoingAway();
|
||||
}
|
||||
|
||||
pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Element.Html.Script) !void {
|
||||
@@ -947,7 +987,7 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele
|
||||
};
|
||||
}
|
||||
|
||||
pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
|
||||
pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
|
||||
if (self.isGoingAway()) {
|
||||
// if we're planning on navigating to another page, don't load this iframe
|
||||
return;
|
||||
@@ -956,41 +996,61 @@ pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse return;
|
||||
var src = iframe.asElement().getAttributeSafe(comptime .wrap("src")) orelse "";
|
||||
if (src.len == 0) {
|
||||
return;
|
||||
src = "about:blank";
|
||||
}
|
||||
|
||||
if (iframe._window != null) {
|
||||
// This frame is being re-navigated. We need to do this through a
|
||||
// scheduleNavigation phase. We can't navigate immediately here, for
|
||||
// the same reason that a "root" page can't immediately navigate:
|
||||
// we could be in the middle of a JS callback or something else that
|
||||
// doesn't exit the page to just suddenly go away.
|
||||
return self.scheduleNavigation(src, .{
|
||||
.reason = .script,
|
||||
.kind = .{ .push = null },
|
||||
}, .{ .iframe = iframe });
|
||||
}
|
||||
|
||||
iframe._executed = true;
|
||||
|
||||
const session = self._session;
|
||||
const frame_id = session.nextFrameId();
|
||||
|
||||
const page_frame = try self.arena.create(Page);
|
||||
const frame_id = session.nextFrameId();
|
||||
|
||||
try Page.init(page_frame, frame_id, session, self);
|
||||
errdefer page_frame.deinit(true);
|
||||
|
||||
self._pending_loads += 1;
|
||||
page_frame.iframe = iframe;
|
||||
iframe._content_window = page_frame.window;
|
||||
iframe._window = page_frame.window;
|
||||
errdefer iframe._window = null;
|
||||
|
||||
// on first load, dispatch frame_created evnet
|
||||
self._session.notification.dispatch(.page_frame_created, &.{
|
||||
.frame_id = frame_id,
|
||||
.parent_id = self._frame_id,
|
||||
.timestamp = timestamp(.monotonic),
|
||||
});
|
||||
|
||||
// navigate will dupe the url
|
||||
const url = try URL.resolve(
|
||||
self.call_arena,
|
||||
self.base(),
|
||||
src,
|
||||
.{ .encode = true },
|
||||
);
|
||||
const url = blk: {
|
||||
if (std.mem.eql(u8, src, "about:blank")) {
|
||||
break :blk "about:blank"; // navigate will handle this special case
|
||||
}
|
||||
break :blk try URL.resolve(
|
||||
self.call_arena, // ok to use, page.navigate dupes this
|
||||
self.base(),
|
||||
src,
|
||||
.{ .encode = true },
|
||||
);
|
||||
};
|
||||
|
||||
page_frame.navigate(url, .{ .reason = .initialFrameNavigation }) catch |err| {
|
||||
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
|
||||
self._pending_loads -= 1;
|
||||
iframe._content_window = null;
|
||||
page_frame.deinit();
|
||||
iframe._window = null;
|
||||
page_frame.deinit(true);
|
||||
return error.IFrameLoadError;
|
||||
};
|
||||
|
||||
@@ -1825,7 +1885,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
|
||||
Element.Html.Track,
|
||||
namespace,
|
||||
attribute_iterator,
|
||||
.{ ._proto = undefined },
|
||||
.{ ._proto = undefined, ._kind = comptime .wrap("subtitles"), ._ready_state = .none },
|
||||
),
|
||||
else => {},
|
||||
},
|
||||
@@ -1909,7 +1969,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
|
||||
.{ ._proto = undefined },
|
||||
),
|
||||
asUint("iframe") => return self.createHtmlElementT(
|
||||
Element.Html.IFrame,
|
||||
IFrame,
|
||||
namespace,
|
||||
attribute_iterator,
|
||||
.{ ._proto = undefined },
|
||||
@@ -1942,10 +2002,10 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const
|
||||
.{ ._proto = undefined, ._tag_name = String.init(undefined, "article", .{}) catch unreachable, ._tag = .article },
|
||||
),
|
||||
asUint("details") => return self.createHtmlElementT(
|
||||
Element.Html.Generic,
|
||||
Element.Html.Details,
|
||||
namespace,
|
||||
attribute_iterator,
|
||||
.{ ._proto = undefined, ._tag_name = String.init(undefined, "details", .{}) catch unreachable, ._tag = .details },
|
||||
.{ ._proto = undefined },
|
||||
),
|
||||
asUint("summary") => return self.createHtmlElementT(
|
||||
Element.Html.Generic,
|
||||
@@ -2338,6 +2398,12 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
|
||||
const previous_sibling = child.previousSibling();
|
||||
const next_sibling = child.nextSibling();
|
||||
|
||||
// Capture child's index before removal for live range updates (DOM spec remove steps 4-7)
|
||||
const child_index_for_ranges: ?u32 = if (self._live_ranges.first != null)
|
||||
parent.getChildIndex(child)
|
||||
else
|
||||
null;
|
||||
|
||||
const children = parent._children.?;
|
||||
switch (children.*) {
|
||||
.one => |n| {
|
||||
@@ -2366,6 +2432,11 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
|
||||
child._parent = null;
|
||||
child._child_link = .{};
|
||||
|
||||
// Update live ranges for removal (DOM spec remove steps 4-7)
|
||||
if (child_index_for_ranges) |idx| {
|
||||
self.updateRangesForNodeRemoval(parent, child, idx);
|
||||
}
|
||||
|
||||
// Handle slot assignment removal before mutation observers
|
||||
if (child.is(Element)) |el| {
|
||||
// Check if the parent was a shadow host
|
||||
@@ -2469,7 +2540,7 @@ pub fn insertNodeRelative(self: *Page, parent: *Node, child: *Node, relative: In
|
||||
pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
|
||||
// caller should have made sure this was the case
|
||||
|
||||
lp.assert(child._parent == null, "Page.insertNodeRelative parent", .{ .url = self.url });
|
||||
lp.assert(child._parent == null, "Page.insertNodeRelative parent", .{});
|
||||
|
||||
const children = blk: {
|
||||
// expand parent._children so that it can take another child
|
||||
@@ -2513,6 +2584,21 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod
|
||||
}
|
||||
child._parent = parent;
|
||||
|
||||
// Update live ranges for insertion (DOM spec insert step 6).
|
||||
// For .before/.after the child was inserted at a specific position;
|
||||
// ranges on parent with offsets past that position must be incremented.
|
||||
// For .append no range update is needed (spec: "if child is non-null").
|
||||
if (self._live_ranges.first != null) {
|
||||
switch (relative) {
|
||||
.append => {},
|
||||
.before, .after => {
|
||||
if (parent.getChildIndex(child)) |idx| {
|
||||
self.updateRangesForNodeInsertion(parent, idx);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Tri-state behavior for mutations:
|
||||
// 1. from_parser=true, parse_mode=document -> no mutations (initial document parse)
|
||||
// 2. from_parser=true, parse_mode=fragment -> mutations (innerHTML additions)
|
||||
@@ -2771,6 +2857,54 @@ pub fn childListChange(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Live range update methods (DOM spec §4.2.3, §4.2.4, §4.7, §4.8) ---
|
||||
|
||||
/// Update all live ranges after a replaceData mutation on a CharacterData node.
|
||||
/// Per DOM spec: insertData = replaceData(offset, 0, data),
|
||||
/// deleteData = replaceData(offset, count, "").
|
||||
/// All parameters are in UTF-16 code unit offsets.
|
||||
pub fn updateRangesForCharacterDataReplace(self: *Page, target: *Node, offset: u32, count: u32, data_len: u32) void {
|
||||
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
||||
while (it) |link| : (it = link.next) {
|
||||
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
||||
ar.updateForCharacterDataReplace(target, offset, count, data_len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all live ranges after a splitText operation.
|
||||
/// Steps 7b-7e of the DOM spec splitText algorithm.
|
||||
/// Steps 7d-7e complement (not overlap) updateRangesForNodeInsertion:
|
||||
/// the insert update handles offsets > child_index, while 7d/7e handle
|
||||
/// offsets == node_index+1 (these are equal values but with > vs == checks).
|
||||
pub fn updateRangesForSplitText(self: *Page, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
|
||||
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
||||
while (it) |link| : (it = link.next) {
|
||||
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
||||
ar.updateForSplitText(target, new_node, offset, parent, node_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all live ranges after a node insertion.
|
||||
/// Per DOM spec insert algorithm step 6: only applies when inserting before a
|
||||
/// non-null reference node.
|
||||
pub fn updateRangesForNodeInsertion(self: *Page, parent: *Node, child_index: u32) void {
|
||||
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
||||
while (it) |link| : (it = link.next) {
|
||||
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
||||
ar.updateForNodeInsertion(parent, child_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all live ranges after a node removal.
|
||||
/// Per DOM spec remove algorithm steps 4-7.
|
||||
pub fn updateRangesForNodeRemoval(self: *Page, parent: *Node, child: *Node, child_index: u32) void {
|
||||
var it: ?*std.DoublyLinkedList.Node = self._live_ranges.first;
|
||||
while (it) |link| : (it = link.next) {
|
||||
const ar: *AbstractRange = @fieldParentPtr("_range_link", link);
|
||||
ar.updateForNodeRemoval(parent, child, child_index);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '')
|
||||
pub fn parseHtmlAsChildren(self: *Page, node: *Node, html: []const u8) !void {
|
||||
const previous_parse_mode = self._parse_mode;
|
||||
@@ -2811,21 +2945,19 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
|
||||
}
|
||||
if (node.is(Element.Html.Script)) |script| {
|
||||
if ((comptime from_parser == false) and script._src.len == 0) {
|
||||
// script was added via JavaScript, but without a src, don't try
|
||||
// to execute it (we'll execute it if/when the src is set)
|
||||
return;
|
||||
// Script was added via JavaScript without a src attribute.
|
||||
// Only skip if it has no inline content either — scripts with
|
||||
// textContent/text should still execute per spec.
|
||||
if (node.firstChild() == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.scriptAddedCallback(from_parser, script) catch |err| {
|
||||
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type, .url = self.url });
|
||||
return err;
|
||||
};
|
||||
} else if (node.is(Element.Html.IFrame)) |iframe| {
|
||||
if ((comptime from_parser == false) and iframe._src.len == 0) {
|
||||
// iframe was added via JavaScript, but without a src
|
||||
return;
|
||||
}
|
||||
|
||||
} else if (node.is(IFrame)) |iframe| {
|
||||
self.iframeAddedCallback(iframe) catch |err| {
|
||||
log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type, .url = self.url });
|
||||
return err;
|
||||
@@ -2953,7 +3085,7 @@ pub const NavigateReason = enum {
|
||||
pub const NavigateOpts = struct {
|
||||
cdp_id: ?i64 = null,
|
||||
reason: NavigateReason = .address_bar,
|
||||
method: Http.Method = .GET,
|
||||
method: HttpClient.Method = .GET,
|
||||
body: ?[]const u8 = null,
|
||||
header: ?[:0]const u8 = null,
|
||||
force: bool = false,
|
||||
@@ -2963,20 +3095,29 @@ pub const NavigateOpts = struct {
|
||||
pub const NavigatedOpts = struct {
|
||||
cdp_id: ?i64 = null,
|
||||
reason: NavigateReason = .address_bar,
|
||||
method: Http.Method = .GET,
|
||||
method: HttpClient.Method = .GET,
|
||||
};
|
||||
|
||||
const NavigationPriority = enum {
|
||||
const NavigationType = enum {
|
||||
form,
|
||||
script,
|
||||
anchor,
|
||||
iframe,
|
||||
};
|
||||
|
||||
const Navigation = union(NavigationType) {
|
||||
form: *Node,
|
||||
script: ?*Page,
|
||||
anchor: *Node,
|
||||
iframe: *IFrame,
|
||||
};
|
||||
|
||||
pub const QueuedNavigation = struct {
|
||||
arena: Allocator,
|
||||
url: [:0]const u8,
|
||||
opts: NavigateOpts,
|
||||
priority: NavigationPriority,
|
||||
is_about_blank: bool,
|
||||
navigation_type: NavigationType,
|
||||
};
|
||||
|
||||
pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
|
||||
@@ -3029,11 +3170,17 @@ pub fn handleClick(self: *Page, target: *Node) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: We need to support targets properly, but this is the most
|
||||
// common case: a click on an anchor navigates the page/frame that
|
||||
// anchor is in.
|
||||
|
||||
// ownerDocument only returns null when `target` is a document, which
|
||||
// it is NOT in this case. Even for a detched node, it'll return self.document
|
||||
try element.focus(self);
|
||||
try self.scheduleNavigation(href, .{
|
||||
.reason = .script,
|
||||
.kind = .{ .push = null },
|
||||
}, .anchor);
|
||||
}, .{ .anchor = target });
|
||||
},
|
||||
.input => |input| {
|
||||
try element.focus(self);
|
||||
@@ -3054,7 +3201,11 @@ pub fn handleClick(self: *Page, target: *Node) !void {
|
||||
|
||||
pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void {
|
||||
const event = keyboard_event.asEvent();
|
||||
const element = self.window._document._active_element orelse return;
|
||||
const element = self.window._document._active_element orelse {
|
||||
keyboard_event.deinit(false, self._session);
|
||||
return;
|
||||
};
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "page keydown", .{
|
||||
.url = self.url,
|
||||
@@ -3127,7 +3278,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
|
||||
|
||||
// so submit_event is still valid when we check _prevent_default
|
||||
submit_event.acquireRef();
|
||||
defer submit_event.deinit(false, self);
|
||||
defer submit_event.deinit(false, self._session);
|
||||
|
||||
try self._event_manager.dispatch(form_element.asEventTarget(), submit_event);
|
||||
// If the submit event was prevented, don't submit the form
|
||||
@@ -3141,8 +3292,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
|
||||
// I don't think this is technically correct, but FormData handles it ok
|
||||
const form_data = try FormData.init(form, submitter_, self);
|
||||
|
||||
const arena = try self.arena_pool.acquire();
|
||||
errdefer self.arena_pool.release(arena);
|
||||
const arena = try self._session.getArena(.{ .debug = "submitForm" });
|
||||
errdefer self._session.releaseArena(arena);
|
||||
|
||||
const encoding = form_element.getAttributeSafe(comptime .wrap("enctype"));
|
||||
|
||||
@@ -3164,7 +3315,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
|
||||
} else {
|
||||
action = try URL.concatQueryString(arena, action, buf.written());
|
||||
}
|
||||
return self.scheduleNavigationWithArena(arena, action, opts, .form);
|
||||
return self.scheduleNavigationWithArena(arena, action, opts, .{ .form = form_element.asNode() });
|
||||
}
|
||||
|
||||
// insertText is a shortcut to insert text into the active element.
|
||||
@@ -3189,7 +3340,7 @@ const RequestCookieOpts = struct {
|
||||
is_http: bool = true,
|
||||
is_navigation: bool = false,
|
||||
};
|
||||
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) Http.Client.RequestCookie {
|
||||
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
|
||||
return .{
|
||||
.jar = &self._session.cookie_jar,
|
||||
.origin = self.url,
|
||||
|
||||
@@ -21,7 +21,8 @@ const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const Http = @import("../http/Http.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const net_http = @import("../network/http.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
@@ -60,7 +61,7 @@ ready_scripts: std.DoublyLinkedList,
|
||||
|
||||
shutdown: bool = false,
|
||||
|
||||
client: *Http.Client,
|
||||
client: *HttpClient,
|
||||
allocator: Allocator,
|
||||
buffer_pool: BufferPool,
|
||||
|
||||
@@ -88,7 +89,7 @@ importmap: std.StringHashMapUnmanaged([:0]const u8),
|
||||
// event).
|
||||
page_notified_of_completion: bool,
|
||||
|
||||
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
|
||||
pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager {
|
||||
return .{
|
||||
.page = page,
|
||||
.async_scripts = .{},
|
||||
@@ -141,7 +142,7 @@ fn clearList(list: *std.DoublyLinkedList) void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
|
||||
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers {
|
||||
var headers = try self.client.newHeaders();
|
||||
try self.page.headersForRequest(self.page.arena, url, &headers);
|
||||
return headers;
|
||||
@@ -634,6 +635,8 @@ pub const Script = struct {
|
||||
debug_transfer_notified_fail: bool = false,
|
||||
debug_transfer_redirecting: bool = false,
|
||||
debug_transfer_intercept_state: u8 = 0,
|
||||
debug_transfer_auth_challenge: bool = false,
|
||||
debug_transfer_easy_id: usize = 0,
|
||||
|
||||
const Kind = enum {
|
||||
module,
|
||||
@@ -673,11 +676,11 @@ pub const Script = struct {
|
||||
self.manager.script_pool.destroy(self);
|
||||
}
|
||||
|
||||
fn startCallback(transfer: *Http.Transfer) !void {
|
||||
fn startCallback(transfer: *HttpClient.Transfer) !void {
|
||||
log.debug(.http, "script fetch start", .{ .req = transfer });
|
||||
}
|
||||
|
||||
fn headerCallback(transfer: *Http.Transfer) !bool {
|
||||
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||
const header = &transfer.response_header.?;
|
||||
self.status = header.status;
|
||||
@@ -711,6 +714,8 @@ pub const Script = struct {
|
||||
.a5 = self.debug_transfer_notified_fail,
|
||||
.a6 = self.debug_transfer_redirecting,
|
||||
.a7 = self.debug_transfer_intercept_state,
|
||||
.a8 = self.debug_transfer_auth_challenge,
|
||||
.a9 = self.debug_transfer_easy_id,
|
||||
.b1 = transfer.id,
|
||||
.b2 = transfer._tries,
|
||||
.b3 = transfer.aborted,
|
||||
@@ -718,6 +723,8 @@ pub const Script = struct {
|
||||
.b5 = transfer._notified_fail,
|
||||
.b6 = transfer._redirecting,
|
||||
.b7 = @intFromEnum(transfer._intercept_state),
|
||||
.b8 = transfer._auth_challenge != null,
|
||||
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
|
||||
});
|
||||
self.header_callback_called = true;
|
||||
self.debug_transfer_id = transfer.id;
|
||||
@@ -727,6 +734,8 @@ pub const Script = struct {
|
||||
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
self.debug_transfer_redirecting = transfer._redirecting;
|
||||
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
|
||||
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
|
||||
}
|
||||
|
||||
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
|
||||
@@ -738,14 +747,14 @@ pub const Script = struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
|
||||
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||
self._dataCallback(transfer, data) catch |err| {
|
||||
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void {
|
||||
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
|
||||
try self.source.remote.appendSlice(self.manager.allocator, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const storage = @import("webapi/storage/storage.zig");
|
||||
@@ -29,47 +30,86 @@ const History = @import("webapi/History.zig");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
const Factory = @import("Factory.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
const QueuedNavigation = Page.QueuedNavigation;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaPool = App.ArenaPool;
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
// Session is like a browser's tab.
|
||||
// It owns the js env and the loader for all the pages of the session.
|
||||
// You can create successively multiple pages for a session, but you must
|
||||
// deinit a page before running another one.
|
||||
// deinit a page before running another one. It manages two distinct lifetimes.
|
||||
//
|
||||
// The first is the lifetime of the Session itself, where pages are created and
|
||||
// removed, but share the same cookie jar and navigation history (etc...)
|
||||
//
|
||||
// The second is as a container the data needed by the full page hierarchy, i.e. \
|
||||
// the root page and all of its frames (and all of their frames.)
|
||||
const Session = @This();
|
||||
|
||||
// These are the fields that remain intact for the duration of the Session
|
||||
browser: *Browser,
|
||||
notification: *Notification,
|
||||
|
||||
// Used to create our Inspector and in the BrowserContext.
|
||||
arena: Allocator,
|
||||
|
||||
cookie_jar: storage.Cookie.Jar,
|
||||
storage_shed: storage.Shed,
|
||||
|
||||
history: History,
|
||||
navigation: Navigation,
|
||||
storage_shed: storage.Shed,
|
||||
notification: *Notification,
|
||||
cookie_jar: storage.Cookie.Jar,
|
||||
|
||||
// These are the fields that get reset whenever the Session's page (the root) is reset.
|
||||
factory: Factory,
|
||||
|
||||
page_arena: Allocator,
|
||||
|
||||
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
|
||||
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
|
||||
|
||||
// Shared resources for all pages in this session.
|
||||
// These live for the duration of the page tree (root + frames).
|
||||
arena_pool: *ArenaPool,
|
||||
|
||||
// In Debug, we use this to see if anything fails to release an arena back to
|
||||
// the pool.
|
||||
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
|
||||
owner: []const u8,
|
||||
count: usize,
|
||||
}) else void = if (IS_DEBUG) .empty else {},
|
||||
|
||||
page: ?Page,
|
||||
|
||||
queued_navigation: std.ArrayList(*Page),
|
||||
// Temporary buffer for about:blank navigations during processing.
|
||||
// We process async navigations first (safe from re-entrance), then sync
|
||||
// about:blank navigations (which may add to queued_navigation).
|
||||
queued_queued_navigation: std.ArrayList(*Page),
|
||||
|
||||
frame_id_gen: u32,
|
||||
|
||||
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
||||
const allocator = browser.app.allocator;
|
||||
const arena = try browser.arena_pool.acquire();
|
||||
errdefer browser.arena_pool.release(arena);
|
||||
const arena_pool = browser.arena_pool;
|
||||
|
||||
const arena = try arena_pool.acquire();
|
||||
errdefer arena_pool.release(arena);
|
||||
|
||||
const page_arena = try arena_pool.acquire();
|
||||
errdefer arena_pool.release(page_arena);
|
||||
|
||||
self.* = .{
|
||||
.page = null,
|
||||
.arena = arena,
|
||||
.arena_pool = arena_pool,
|
||||
.page_arena = page_arena,
|
||||
.factory = Factory.init(page_arena),
|
||||
.history = .{},
|
||||
.frame_id_gen = 0,
|
||||
// The prototype (EventTarget) for Navigation is created when a Page is created.
|
||||
.navigation = .{ ._proto = undefined },
|
||||
.storage_shed = .{},
|
||||
.browser = browser,
|
||||
.queued_navigation = .{},
|
||||
.queued_queued_navigation = .{},
|
||||
.notification = notification,
|
||||
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
||||
};
|
||||
@@ -79,11 +119,11 @@ pub fn deinit(self: *Session) void {
|
||||
if (self.page != null) {
|
||||
self.removePage();
|
||||
}
|
||||
const browser = self.browser;
|
||||
|
||||
self.cookie_jar.deinit();
|
||||
self.storage_shed.deinit(browser.app.allocator);
|
||||
browser.arena_pool.release(self.arena);
|
||||
|
||||
self.storage_shed.deinit(self.browser.app.allocator);
|
||||
self.arena_pool.release(self.page_arena);
|
||||
self.arena_pool.release(self.arena);
|
||||
}
|
||||
|
||||
// NOTE: the caller is not the owner of the returned value,
|
||||
@@ -113,33 +153,137 @@ pub fn removePage(self: *Session) void {
|
||||
self.notification.dispatch(.page_remove, .{});
|
||||
lp.assert(self.page != null, "Session.removePage - page is null", .{});
|
||||
|
||||
self.page.?.deinit();
|
||||
self.page.?.deinit(false);
|
||||
self.page = null;
|
||||
|
||||
self.navigation.onRemovePage();
|
||||
self.resetPageResources();
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.browser, "remove page", .{});
|
||||
}
|
||||
}
|
||||
|
||||
pub const GetArenaOpts = struct {
|
||||
debug: []const u8,
|
||||
};
|
||||
|
||||
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
|
||||
const allocator = try self.arena_pool.acquire();
|
||||
if (comptime IS_DEBUG) {
|
||||
// Use session's arena (not page_arena) since page_arena gets reset between pages
|
||||
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
|
||||
if (gop.found_existing and gop.value_ptr.count != 0) {
|
||||
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
|
||||
@panic("ArenaPool Double Use");
|
||||
}
|
||||
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
|
||||
}
|
||||
return allocator;
|
||||
}
|
||||
|
||||
pub fn releaseArena(self: *Session, allocator: Allocator) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
|
||||
if (found.count != 1) {
|
||||
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
|
||||
if (comptime builtin.is_test) {
|
||||
@panic("ArenaPool Double Free");
|
||||
}
|
||||
return;
|
||||
}
|
||||
found.count = 0;
|
||||
}
|
||||
return self.arena_pool.release(allocator);
|
||||
}
|
||||
|
||||
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
|
||||
const key = key_ orelse {
|
||||
var opaque_origin: [36]u8 = undefined;
|
||||
@import("../id.zig").uuidv4(&opaque_origin);
|
||||
// Origin.init will dupe opaque_origin. It's fine that this doesn't
|
||||
// get added to self.origins. In fact, it further isolates it. When the
|
||||
// context is freed, it'll call session.releaseOrigin which will free it.
|
||||
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
|
||||
};
|
||||
|
||||
const gop = try self.origins.getOrPut(self.arena, key);
|
||||
if (gop.found_existing) {
|
||||
const origin = gop.value_ptr.*;
|
||||
origin.rc += 1;
|
||||
return origin;
|
||||
}
|
||||
|
||||
errdefer _ = self.origins.remove(key);
|
||||
|
||||
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
|
||||
gop.key_ptr.* = origin.key;
|
||||
gop.value_ptr.* = origin;
|
||||
return origin;
|
||||
}
|
||||
|
||||
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
|
||||
const rc = origin.rc;
|
||||
if (rc == 1) {
|
||||
_ = self.origins.remove(origin.key);
|
||||
origin.deinit(self.browser.app);
|
||||
} else {
|
||||
origin.rc = rc - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset page_arena and factory for a clean slate.
|
||||
/// Called when root page is removed.
|
||||
fn resetPageResources(self: *Session) void {
|
||||
// Check for arena leaks before releasing
|
||||
if (comptime IS_DEBUG) {
|
||||
var it = self._arena_pool_leak_track.valueIterator();
|
||||
while (it.next()) |value_ptr| {
|
||||
if (value_ptr.count > 0) {
|
||||
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
|
||||
}
|
||||
}
|
||||
self._arena_pool_leak_track.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// All origins should have been released when contexts were destroyed
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(self.origins.count() == 0);
|
||||
}
|
||||
// Defensive cleanup in case origins leaked
|
||||
{
|
||||
const app = self.browser.app;
|
||||
var it = self.origins.valueIterator();
|
||||
while (it.next()) |value| {
|
||||
value.*.deinit(app);
|
||||
}
|
||||
self.origins.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// Release old page_arena and acquire fresh one
|
||||
self.frame_id_gen = 0;
|
||||
self.arena_pool.reset(self.page_arena, 64 * 1024);
|
||||
self.factory = Factory.init(self.page_arena);
|
||||
}
|
||||
|
||||
pub fn replacePage(self: *Session) !*Page {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.browser, "replace page", .{});
|
||||
}
|
||||
|
||||
lp.assert(self.page != null, "Session.replacePage null page", .{});
|
||||
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
|
||||
|
||||
var current = self.page.?;
|
||||
const frame_id = current._frame_id;
|
||||
const parent = current.parent;
|
||||
current.deinit();
|
||||
current.deinit(true);
|
||||
|
||||
self.resetPageResources();
|
||||
self.browser.env.memoryPressureNotification(.moderate);
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, frame_id, self, parent);
|
||||
try Page.init(page, frame_id, self, null);
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -174,10 +318,11 @@ pub fn wait(self: *Session, wait_ms: u32) WaitResult {
|
||||
|
||||
switch (wait_result) {
|
||||
.done => {
|
||||
if (page._queued_navigation == null) {
|
||||
if (self.queued_navigation.items.len == 0) {
|
||||
return .done;
|
||||
}
|
||||
page = self.processScheduledNavigation(page) catch return .done;
|
||||
self.processQueuedNavigation() catch return .done;
|
||||
page = &self.page.?; // might have changed
|
||||
},
|
||||
else => |result| return result,
|
||||
}
|
||||
@@ -229,7 +374,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
}
|
||||
},
|
||||
.html, .complete => {
|
||||
if (page._queued_navigation != null) {
|
||||
if (self.queued_navigation.items.len != 0) {
|
||||
return .done;
|
||||
}
|
||||
|
||||
@@ -261,7 +406,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
std.debug.assert(http_client.intercepted == 0);
|
||||
}
|
||||
|
||||
const ms: u64 = ms_to_next_task orelse blk: {
|
||||
var ms: u64 = ms_to_next_task orelse blk: {
|
||||
if (wait_ms - ms_remaining < 100) {
|
||||
if (comptime builtin.is_test) {
|
||||
return .done;
|
||||
@@ -288,7 +433,13 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
// Same as above, except we have a scheduled task,
|
||||
// it just happens to be too far into the future
|
||||
// compared to how long we were told to wait.
|
||||
return .done;
|
||||
if (!browser.hasBackgroundTasks()) {
|
||||
return .done;
|
||||
}
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
// background tasks. We'll wait for them.
|
||||
browser.waitForBackgroundTasks();
|
||||
ms = 20;
|
||||
}
|
||||
|
||||
// We have a task to run in the not-so-distant future.
|
||||
@@ -339,42 +490,145 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page {
|
||||
const browser = self.browser;
|
||||
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
||||
const list = &self.queued_navigation;
|
||||
|
||||
const qn = current_page._queued_navigation.?;
|
||||
// take ownership of the page's queued navigation
|
||||
current_page._queued_navigation = null;
|
||||
defer browser.arena_pool.release(qn.arena);
|
||||
// Check if page is already queued
|
||||
for (list.items) |existing| {
|
||||
if (existing == page) {
|
||||
// Already queued
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const frame_id, const parent = blk: {
|
||||
const page = &self.page.?;
|
||||
const frame_id = page._frame_id;
|
||||
const parent = page.parent;
|
||||
return list.append(self.arena, page);
|
||||
}
|
||||
|
||||
browser.http_client.abort();
|
||||
self.removePage();
|
||||
fn processQueuedNavigation(self: *Session) !void {
|
||||
const navigations = &self.queued_navigation;
|
||||
|
||||
break :blk .{ frame_id, parent };
|
||||
if (self.page.?._queued_navigation != null) {
|
||||
// This is both an optimization and a simplification of sorts. If the
|
||||
// root page is navigating, then we don't need to process any other
|
||||
// navigation. Also, the navigation for the root page and for a frame
|
||||
// is different enough that have two distinct code blocks is, imo,
|
||||
// better. Yes, there will be duplication.
|
||||
navigations.clearRetainingCapacity();
|
||||
return self.processRootQueuedNavigation();
|
||||
}
|
||||
|
||||
const about_blank_queue = &self.queued_queued_navigation;
|
||||
defer about_blank_queue.clearRetainingCapacity();
|
||||
|
||||
// First pass: process async navigations (non-about:blank)
|
||||
// These cannot cause re-entrant navigation scheduling
|
||||
for (navigations.items) |page| {
|
||||
const qn = page._queued_navigation.?;
|
||||
|
||||
if (qn.is_about_blank) {
|
||||
// Defer about:blank to second pass
|
||||
try about_blank_queue.append(self.arena, page);
|
||||
continue;
|
||||
}
|
||||
|
||||
try self.processFrameNavigation(page, qn);
|
||||
}
|
||||
|
||||
// Clear the queue after first pass
|
||||
navigations.clearRetainingCapacity();
|
||||
|
||||
// Second pass: process synchronous navigations (about:blank)
|
||||
// These may trigger new navigations which go into queued_navigation
|
||||
for (about_blank_queue.items) |page| {
|
||||
const qn = page._queued_navigation.?;
|
||||
try self.processFrameNavigation(page, qn);
|
||||
}
|
||||
|
||||
// Safety: Remove any about:blank navigations that were queued during the
|
||||
// second pass to prevent infinite loops
|
||||
var i: usize = 0;
|
||||
while (i < navigations.items.len) {
|
||||
const page = navigations.items[i];
|
||||
if (page._queued_navigation) |qn| {
|
||||
if (qn.is_about_blank) {
|
||||
log.warn(.page, "recursive about blank", .{});
|
||||
_ = navigations.swapRemove(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
|
||||
lp.assert(page.parent != null, "root queued navigation", .{});
|
||||
|
||||
const iframe = page.iframe.?;
|
||||
const parent = page.parent.?;
|
||||
|
||||
page._queued_navigation = null;
|
||||
defer self.releaseArena(qn.arena);
|
||||
|
||||
errdefer iframe._window = null;
|
||||
|
||||
if (page._parent_notified) {
|
||||
// we already notified the parent that we had loaded
|
||||
parent._pending_loads += 1;
|
||||
}
|
||||
|
||||
const frame_id = page._frame_id;
|
||||
page.deinit(true);
|
||||
page.* = undefined;
|
||||
|
||||
try Page.init(page, frame_id, self, parent);
|
||||
errdefer page.deinit(true);
|
||||
|
||||
page.iframe = iframe;
|
||||
iframe._window = page.window;
|
||||
|
||||
page.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued frame navigation error", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn processRootQueuedNavigation(self: *Session) !void {
|
||||
const current_page = &self.page.?;
|
||||
const frame_id = current_page._frame_id;
|
||||
|
||||
// create a copy before the page is cleared
|
||||
const qn = current_page._queued_navigation.?;
|
||||
current_page._queued_navigation = null;
|
||||
|
||||
defer self.arena_pool.release(qn.arena);
|
||||
|
||||
// HACK
|
||||
// Mark as released in tracking BEFORE removePage clears the map.
|
||||
// We can't call releaseArena() because that would also return the arena
|
||||
// to the pool, making the memory invalid before we use qn.url/qn.opts.
|
||||
if (comptime IS_DEBUG) {
|
||||
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
|
||||
found.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.removePage();
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, frame_id, self, parent);
|
||||
const new_page = &self.page.?;
|
||||
try Page.init(new_page, frame_id, self, null);
|
||||
|
||||
// Creates a new NavigationEventTarget for this page.
|
||||
try self.navigation.onNewPage(page);
|
||||
try self.navigation.onNewPage(new_page);
|
||||
|
||||
// start JS env
|
||||
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
|
||||
self.notification.dispatch(.page_created, page);
|
||||
self.notification.dispatch(.page_created, new_page);
|
||||
|
||||
page.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
|
||||
new_page.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued navigation error", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
pub fn nextFrameId(self: *Session) u32 {
|
||||
|
||||
@@ -167,17 +167,17 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
|
||||
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
|
||||
|
||||
const path_to_encode = url[path_start..path_end];
|
||||
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true);
|
||||
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
|
||||
|
||||
const encoded_query = if (query_start) |qs| blk: {
|
||||
const query_to_encode = url[qs + 1 .. query_end];
|
||||
const encoded = try percentEncodeSegment(allocator, query_to_encode, false);
|
||||
const encoded = try percentEncodeSegment(allocator, query_to_encode, .query);
|
||||
break :blk encoded;
|
||||
} else null;
|
||||
|
||||
const encoded_fragment = if (fragment_start) |fs| blk: {
|
||||
const fragment_to_encode = url[fs + 1 ..];
|
||||
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false);
|
||||
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query);
|
||||
break :blk encoded;
|
||||
} else null;
|
||||
|
||||
@@ -204,11 +204,13 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
|
||||
return buf.items[0 .. buf.items.len - 1 :0];
|
||||
}
|
||||
|
||||
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 {
|
||||
const EncodeSet = enum { path, query, userinfo };
|
||||
|
||||
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
|
||||
// Check if encoding is needed
|
||||
var needs_encoding = false;
|
||||
for (segment) |c| {
|
||||
if (shouldPercentEncode(c, is_path)) {
|
||||
if (shouldPercentEncode(c, encode_set)) {
|
||||
needs_encoding = true;
|
||||
break;
|
||||
}
|
||||
@@ -235,7 +237,7 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPercentEncode(c, is_path)) {
|
||||
if (shouldPercentEncode(c, encode_set)) {
|
||||
try buf.writer(allocator).print("%{X:0>2}", .{c});
|
||||
} else {
|
||||
try buf.append(allocator, c);
|
||||
@@ -245,16 +247,17 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool {
|
||||
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
|
||||
return switch (c) {
|
||||
// Unreserved characters (RFC 3986)
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
|
||||
// sub-delims allowed in both path and query
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false,
|
||||
// Separators allowed in both path and query
|
||||
'/', ':', '@' => false,
|
||||
// Query-specific: '?' is allowed in queries but not in paths
|
||||
'?' => comptime is_path,
|
||||
// sub-delims allowed in path/query but some must be encoded in userinfo
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
|
||||
';', '=' => encode_set == .userinfo,
|
||||
// Separators: userinfo must encode these
|
||||
'/', ':', '@' => encode_set == .userinfo,
|
||||
// '?' is allowed in queries but not in paths or userinfo
|
||||
'?' => encode_set != .query,
|
||||
// Everything else needs encoding (including space)
|
||||
else => true,
|
||||
};
|
||||
@@ -514,7 +517,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
||||
const search = getSearch(current);
|
||||
const hash = getHash(current);
|
||||
|
||||
// Check if the host includes a port
|
||||
// Check if the new value includes a port
|
||||
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
|
||||
const clean_host = if (colon_pos) |pos| blk: {
|
||||
const port_str = value[pos + 1 ..];
|
||||
@@ -526,7 +529,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
||||
break :blk value[0..pos];
|
||||
}
|
||||
break :blk value;
|
||||
} else value;
|
||||
} else blk: {
|
||||
// No port in new value - preserve existing port
|
||||
const current_port = getPort(current);
|
||||
if (current_port.len > 0) {
|
||||
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port });
|
||||
}
|
||||
break :blk value;
|
||||
};
|
||||
|
||||
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
|
||||
}
|
||||
@@ -544,6 +554,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato
|
||||
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
|
||||
const hostname = getHostname(current);
|
||||
const protocol = getProtocol(current);
|
||||
const pathname = getPathname(current);
|
||||
const search = getSearch(current);
|
||||
const hash = getHash(current);
|
||||
|
||||
// Handle null or default ports
|
||||
const new_host = if (value) |port_str| blk: {
|
||||
@@ -560,7 +573,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator)
|
||||
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
|
||||
} else hostname;
|
||||
|
||||
return setHost(current, new_host, allocator);
|
||||
return buildUrl(allocator, protocol, new_host, pathname, search, hash);
|
||||
}
|
||||
|
||||
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||
@@ -608,6 +621,64 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
|
||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||
}
|
||||
|
||||
pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||
const protocol = getProtocol(current);
|
||||
const host = getHost(current);
|
||||
const pathname = getPathname(current);
|
||||
const search = getSearch(current);
|
||||
const hash = getHash(current);
|
||||
const password = getPassword(current);
|
||||
|
||||
const encoded_username = try percentEncodeSegment(allocator, value, .userinfo);
|
||||
return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash);
|
||||
}
|
||||
|
||||
pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
|
||||
const protocol = getProtocol(current);
|
||||
const host = getHost(current);
|
||||
const pathname = getPathname(current);
|
||||
const search = getSearch(current);
|
||||
const hash = getHash(current);
|
||||
const username = getUsername(current);
|
||||
|
||||
const encoded_password = try percentEncodeSegment(allocator, value, .userinfo);
|
||||
return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash);
|
||||
}
|
||||
|
||||
fn buildUrlWithUserInfo(
|
||||
allocator: Allocator,
|
||||
protocol: []const u8,
|
||||
username: []const u8,
|
||||
password: []const u8,
|
||||
host: []const u8,
|
||||
pathname: []const u8,
|
||||
search: []const u8,
|
||||
hash: []const u8,
|
||||
) ![:0]const u8 {
|
||||
if (username.len == 0 and password.len == 0) {
|
||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||
} else if (password.len == 0) {
|
||||
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{
|
||||
protocol,
|
||||
username,
|
||||
host,
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
}, 0);
|
||||
} else {
|
||||
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{
|
||||
protocol,
|
||||
username,
|
||||
password,
|
||||
host,
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
|
||||
if (query_string.len == 0) {
|
||||
return arena.dupeZ(u8, url);
|
||||
@@ -961,6 +1032,10 @@ test "URL: ensureEncoded" {
|
||||
.url = "https://example.com/path?value=100% done",
|
||||
.expected = "https://example.com/path?value=100%25%20done",
|
||||
},
|
||||
.{
|
||||
.url = "about:blank",
|
||||
.expected = "about:blank",
|
||||
},
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
|
||||
@@ -480,10 +480,11 @@ fn consumeName(self: *Tokenizer) []const u8 {
|
||||
self.consumeEscape();
|
||||
},
|
||||
0x0 => self.advance(1),
|
||||
'\x80'...'\xBF', '\xC0'...'\xEF', '\xF0'...'\xFF' => {
|
||||
// This byte *is* part of a multi-byte code point,
|
||||
// we’ll end up copying the whole code point before this loop does something else.
|
||||
self.advance(1);
|
||||
'\x80'...'\xFF' => {
|
||||
// Non-ASCII: advance over the complete UTF-8 code point in one step.
|
||||
// Using consumeChar() instead of advance(1) ensures we never land on
|
||||
// a continuation byte, which advance() asserts against.
|
||||
self.consumeChar();
|
||||
},
|
||||
else => {
|
||||
if (self.hasNonAsciiAt(0)) {
|
||||
|
||||
526
src/browser/interactive.zig
Normal file
526
src/browser/interactive.zig
Normal file
@@ -0,0 +1,526 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const InteractivityType = enum {
|
||||
native,
|
||||
aria,
|
||||
contenteditable,
|
||||
listener,
|
||||
focusable,
|
||||
};
|
||||
|
||||
pub const InteractiveElement = struct {
|
||||
node: *Node,
|
||||
tag_name: []const u8,
|
||||
role: ?[]const u8,
|
||||
name: ?[]const u8,
|
||||
interactivity_type: InteractivityType,
|
||||
listener_types: []const []const u8,
|
||||
disabled: bool,
|
||||
tab_index: i32,
|
||||
id: ?[]const u8,
|
||||
class: ?[]const u8,
|
||||
href: ?[]const u8,
|
||||
input_type: ?[]const u8,
|
||||
value: ?[]const u8,
|
||||
element_name: ?[]const u8,
|
||||
placeholder: ?[]const u8,
|
||||
|
||||
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
|
||||
try jw.objectField("tagName");
|
||||
try jw.write(self.tag_name);
|
||||
|
||||
try jw.objectField("role");
|
||||
try jw.write(self.role);
|
||||
|
||||
try jw.objectField("name");
|
||||
try jw.write(self.name);
|
||||
|
||||
try jw.objectField("type");
|
||||
try jw.write(@tagName(self.interactivity_type));
|
||||
|
||||
if (self.listener_types.len > 0) {
|
||||
try jw.objectField("listeners");
|
||||
try jw.beginArray();
|
||||
for (self.listener_types) |lt| {
|
||||
try jw.write(lt);
|
||||
}
|
||||
try jw.endArray();
|
||||
}
|
||||
|
||||
if (self.disabled) {
|
||||
try jw.objectField("disabled");
|
||||
try jw.write(true);
|
||||
}
|
||||
|
||||
try jw.objectField("tabIndex");
|
||||
try jw.write(self.tab_index);
|
||||
|
||||
if (self.id) |v| {
|
||||
try jw.objectField("id");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.class) |v| {
|
||||
try jw.objectField("class");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.href) |v| {
|
||||
try jw.objectField("href");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.input_type) |v| {
|
||||
try jw.objectField("inputType");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.value) |v| {
|
||||
try jw.objectField("value");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.element_name) |v| {
|
||||
try jw.objectField("elementName");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.placeholder) |v| {
|
||||
try jw.objectField("placeholder");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
/// Collect all interactive elements under `root`.
|
||||
pub fn collectInteractiveElements(
|
||||
root: *Node,
|
||||
arena: Allocator,
|
||||
page: *Page,
|
||||
) ![]InteractiveElement {
|
||||
// Pre-build a map of event_target pointer → event type names,
|
||||
// so classify and getListenerTypes are both O(1) per element.
|
||||
const listener_targets = try buildListenerTargetMap(page, arena);
|
||||
|
||||
var results: std.ArrayList(InteractiveElement) = .empty;
|
||||
|
||||
var tw = TreeWalker.Full.init(root, .{});
|
||||
while (tw.next()) |node| {
|
||||
const el = node.is(Element) orelse continue;
|
||||
const html_el = el.is(Element.Html) orelse continue;
|
||||
|
||||
// Skip non-visual elements that are never user-interactive.
|
||||
switch (el.getTag()) {
|
||||
.script, .style, .link, .meta, .head, .noscript, .template => continue,
|
||||
else => {},
|
||||
}
|
||||
|
||||
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
|
||||
|
||||
const listener_types = getListenerTypes(
|
||||
el.asEventTarget(),
|
||||
listener_targets,
|
||||
);
|
||||
|
||||
try results.append(arena, .{
|
||||
.node = node,
|
||||
.tag_name = el.getTagNameLower(),
|
||||
.role = getRole(el),
|
||||
.name = getAccessibleName(el),
|
||||
.interactivity_type = itype,
|
||||
.listener_types = listener_types,
|
||||
.disabled = isDisabled(el),
|
||||
.tab_index = html_el.getTabIndex(),
|
||||
.id = el.getAttributeSafe(comptime .wrap("id")),
|
||||
.class = el.getAttributeSafe(comptime .wrap("class")),
|
||||
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
|
||||
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
|
||||
else
|
||||
null,
|
||||
.input_type = getInputType(el),
|
||||
.value = getInputValue(el),
|
||||
.element_name = el.getAttributeSafe(comptime .wrap("name")),
|
||||
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||
});
|
||||
}
|
||||
|
||||
return results.items;
|
||||
}
|
||||
|
||||
const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
|
||||
|
||||
/// Pre-build a map from event_target pointer → list of event type names.
|
||||
/// This lets both classifyInteractivity (O(1) "has any?") and
|
||||
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
|
||||
fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
|
||||
var map = ListenerTargetMap{};
|
||||
|
||||
// addEventListener registrations
|
||||
var it = page._event_manager.lookup.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const list = entry.value_ptr.*;
|
||||
if (list.first != null) {
|
||||
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .empty;
|
||||
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
|
||||
}
|
||||
}
|
||||
|
||||
// Inline handlers (onclick, onmousedown, etc.)
|
||||
var attr_it = page._event_target_attr_listeners.iterator();
|
||||
while (attr_it.next()) |entry| {
|
||||
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
|
||||
if (!gop.found_existing) gop.value_ptr.* = .empty;
|
||||
// Strip "on" prefix to get the event type name.
|
||||
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
fn classifyInteractivity(
|
||||
el: *Element,
|
||||
html_el: *Element.Html,
|
||||
listener_targets: ListenerTargetMap,
|
||||
) ?InteractivityType {
|
||||
// 1. Native interactive by tag
|
||||
switch (el.getTag()) {
|
||||
.button, .summary, .details, .select, .textarea => return .native,
|
||||
.anchor, .area => {
|
||||
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
|
||||
},
|
||||
.input => {
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
if (input._input_type != .hidden) return .native;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// 2. ARIA interactive role
|
||||
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
|
||||
if (isInteractiveRole(role)) return .aria;
|
||||
}
|
||||
|
||||
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
|
||||
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
|
||||
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
|
||||
}
|
||||
|
||||
// 4. Event listeners (addEventListener or inline handlers)
|
||||
const et_ptr = @intFromPtr(html_el.asEventTarget());
|
||||
if (listener_targets.get(et_ptr) != null) return .listener;
|
||||
|
||||
// 5. Explicitly focusable via tabindex.
|
||||
// Only count elements with an EXPLICIT tabindex attribute,
|
||||
// since getTabIndex() returns 0 for all interactive tags by default
|
||||
// (including anchors without href and hidden inputs).
|
||||
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
|
||||
if (html_el.getTabIndex() >= 0) return .focusable;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isInteractiveRole(role: []const u8) bool {
|
||||
const interactive_roles = [_][]const u8{
|
||||
"button", "link", "tab", "menuitem",
|
||||
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
|
||||
"radio", "slider", "spinbutton", "searchbox",
|
||||
"combobox", "option", "treeitem",
|
||||
};
|
||||
for (interactive_roles) |r| {
|
||||
if (std.ascii.eqlIgnoreCase(role, r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn getRole(el: *Element) ?[]const u8 {
|
||||
// Explicit role attribute takes precedence
|
||||
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
|
||||
|
||||
// Implicit role from tag
|
||||
return switch (el.getTag()) {
|
||||
.button, .summary => "button",
|
||||
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
|
||||
.input => blk: {
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
break :blk switch (input._input_type) {
|
||||
.text, .tel, .url, .email => "textbox",
|
||||
.checkbox => "checkbox",
|
||||
.radio => "radio",
|
||||
.button, .submit, .reset, .image => "button",
|
||||
.range => "slider",
|
||||
.number => "spinbutton",
|
||||
.search => "searchbox",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
break :blk null;
|
||||
},
|
||||
.select => "combobox",
|
||||
.textarea => "textbox",
|
||||
.details => "group",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn getAccessibleName(el: *Element) ?[]const u8 {
|
||||
// aria-label
|
||||
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
|
||||
if (v.len > 0) return v;
|
||||
}
|
||||
|
||||
// alt (for img, input[type=image])
|
||||
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
|
||||
if (v.len > 0) return v;
|
||||
}
|
||||
|
||||
// title
|
||||
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
|
||||
if (v.len > 0) return v;
|
||||
}
|
||||
|
||||
// placeholder
|
||||
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
|
||||
if (v.len > 0) return v;
|
||||
}
|
||||
|
||||
// value (for buttons)
|
||||
if (el.getTag() == .input) {
|
||||
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
|
||||
if (v.len > 0) return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Text content (first non-empty text node, trimmed)
|
||||
return getTextContent(el.asNode());
|
||||
}
|
||||
|
||||
fn getTextContent(node: *Node) ?[]const u8 {
|
||||
var tw = TreeWalker.FullExcludeSelf.init(node, .{});
|
||||
while (tw.next()) |child| {
|
||||
// Skip text inside script/style elements.
|
||||
if (child.is(Element)) |el| {
|
||||
switch (el.getTag()) {
|
||||
.script, .style => {
|
||||
tw.skipChildren();
|
||||
continue;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
if (child.is(Node.CData)) |cdata| {
|
||||
if (cdata.is(Node.CData.Text)) |text| {
|
||||
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
|
||||
if (content.len > 0) return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isDisabled(el: *Element) bool {
|
||||
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
|
||||
return isDisabledByFieldset(el);
|
||||
}
|
||||
|
||||
/// Check if an element is disabled by an ancestor <fieldset disabled>.
|
||||
/// Per spec, elements inside the first <legend> child of a disabled fieldset
|
||||
/// are NOT disabled by that fieldset.
|
||||
fn isDisabledByFieldset(el: *Element) bool {
|
||||
const element_node = el.asNode();
|
||||
var current: ?*Node = element_node._parent;
|
||||
while (current) |node| {
|
||||
current = node._parent;
|
||||
const ancestor = node.is(Element) orelse continue;
|
||||
|
||||
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
||||
// Check if element is inside the first <legend> child of this fieldset
|
||||
var child = ancestor.firstElementChild();
|
||||
while (child) |c| {
|
||||
if (c.getTag() == .legend) {
|
||||
if (c.asNode().contains(element_node)) return false;
|
||||
break;
|
||||
}
|
||||
child = c.nextElementSibling();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn getInputType(el: *Element) ?[]const u8 {
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
return input._input_type.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn getInputValue(el: *Element) ?[]const u8 {
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
return input.getValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get all event listener types registered on this target.
|
||||
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
|
||||
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
|
||||
return &.{};
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn testInteractive(html: []const u8) ![]InteractiveElement {
|
||||
const page = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const doc = page.window._document;
|
||||
const div = try doc.createElement("div", null, page);
|
||||
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||
|
||||
return collectInteractiveElements(div.asNode(), page.call_arena, page);
|
||||
}
|
||||
|
||||
test "browser.interactive: button" {
|
||||
const elements = try testInteractive("<button>Click me</button>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual("button", elements[0].tag_name);
|
||||
try testing.expectEqual("button", elements[0].role.?);
|
||||
try testing.expectEqual("Click me", elements[0].name.?);
|
||||
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
|
||||
}
|
||||
|
||||
test "browser.interactive: anchor with href" {
|
||||
const elements = try testInteractive("<a href=\"/page\">Link</a>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual("a", elements[0].tag_name);
|
||||
try testing.expectEqual("link", elements[0].role.?);
|
||||
try testing.expectEqual("Link", elements[0].name.?);
|
||||
}
|
||||
|
||||
test "browser.interactive: anchor without href" {
|
||||
const elements = try testInteractive("<a>Not a link</a>");
|
||||
try testing.expectEqual(0, elements.len);
|
||||
}
|
||||
|
||||
test "browser.interactive: input types" {
|
||||
const elements = try testInteractive(
|
||||
\\<input type="text" placeholder="Search">
|
||||
\\<input type="hidden" name="csrf">
|
||||
);
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual("input", elements[0].tag_name);
|
||||
try testing.expectEqual("text", elements[0].input_type.?);
|
||||
try testing.expectEqual("Search", elements[0].placeholder.?);
|
||||
}
|
||||
|
||||
test "browser.interactive: select and textarea" {
|
||||
const elements = try testInteractive(
|
||||
\\<select name="color"><option>Red</option></select>
|
||||
\\<textarea name="msg"></textarea>
|
||||
);
|
||||
try testing.expectEqual(2, elements.len);
|
||||
try testing.expectEqual("select", elements[0].tag_name);
|
||||
try testing.expectEqual("textarea", elements[1].tag_name);
|
||||
}
|
||||
|
||||
test "browser.interactive: aria role" {
|
||||
const elements = try testInteractive("<div role=\"button\">Custom</div>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual("div", elements[0].tag_name);
|
||||
try testing.expectEqual("button", elements[0].role.?);
|
||||
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
|
||||
}
|
||||
|
||||
test "browser.interactive: contenteditable" {
|
||||
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
|
||||
}
|
||||
|
||||
test "browser.interactive: tabindex" {
|
||||
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
|
||||
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
|
||||
}
|
||||
|
||||
test "browser.interactive: disabled" {
|
||||
const elements = try testInteractive("<button disabled>Off</button>");
|
||||
try testing.expectEqual(1, elements.len);
|
||||
try testing.expect(elements[0].disabled);
|
||||
}
|
||||
|
||||
test "browser.interactive: disabled by fieldset" {
|
||||
const elements = try testInteractive(
|
||||
\\<fieldset disabled>
|
||||
\\ <button>Disabled</button>
|
||||
\\ <legend><button>In legend</button></legend>
|
||||
\\</fieldset>
|
||||
);
|
||||
try testing.expectEqual(2, elements.len);
|
||||
// Button outside legend is disabled by fieldset
|
||||
try testing.expect(elements[0].disabled);
|
||||
// Button inside first legend is NOT disabled
|
||||
try testing.expect(!elements[1].disabled);
|
||||
}
|
||||
|
||||
test "browser.interactive: non-interactive div" {
|
||||
const elements = try testInteractive("<div>Just text</div>");
|
||||
try testing.expectEqual(0, elements.len);
|
||||
}
|
||||
|
||||
test "browser.interactive: details and summary" {
|
||||
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
|
||||
try testing.expectEqual(2, elements.len);
|
||||
try testing.expectEqual("details", elements[0].tag_name);
|
||||
try testing.expectEqual("summary", elements[1].tag_name);
|
||||
}
|
||||
|
||||
test "browser.interactive: mixed elements" {
|
||||
const elements = try testInteractive(
|
||||
\\<div>
|
||||
\\ <a href="/home">Home</a>
|
||||
\\ <p>Some text</p>
|
||||
\\ <button id="btn1">Submit</button>
|
||||
\\ <input type="email" placeholder="Email">
|
||||
\\ <div>Not interactive</div>
|
||||
\\ <div role="tab">Tab</div>
|
||||
\\</div>
|
||||
);
|
||||
try testing.expectEqual(4, elements.len);
|
||||
}
|
||||
@@ -60,6 +60,11 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context)
|
||||
ctx.local = &self.local;
|
||||
}
|
||||
|
||||
pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void {
|
||||
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
|
||||
self.init(isolate);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Caller) void {
|
||||
const ctx = self.local.ctx;
|
||||
const call_depth = ctx.call_depth - 1;
|
||||
@@ -441,6 +446,11 @@ pub const FunctionCallbackInfo = struct {
|
||||
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
|
||||
}
|
||||
|
||||
pub fn getData(self: FunctionCallbackInfo) ?*anyopaque {
|
||||
const data = v8.v8__FunctionCallbackInfo__Data(self.handle) orelse return null;
|
||||
return v8.v8__External__Value(@ptrCast(data));
|
||||
}
|
||||
|
||||
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
|
||||
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
|
||||
}
|
||||
@@ -499,6 +509,7 @@ pub const Function = struct {
|
||||
as_typed_array: bool = false,
|
||||
null_as_undefined: bool = false,
|
||||
cache: ?Caching = null,
|
||||
embedded_receiver: bool = false,
|
||||
|
||||
// We support two ways to cache a value directly into a v8::Object. The
|
||||
// difference between the two is like the difference between a Map
|
||||
@@ -569,6 +580,9 @@ pub const Function = struct {
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
if (comptime opts.static) {
|
||||
args = try getArgs(F, 0, local, info);
|
||||
} else if (comptime opts.embedded_receiver) {
|
||||
args = try getArgs(F, 1, local, info);
|
||||
@field(args, "0") = @ptrCast(@alignCast(info.getData() orelse unreachable));
|
||||
} else {
|
||||
args = try getArgs(F, 1, local, info);
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@@ -720,7 +734,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
|
||||
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
|
||||
const slice_type = last_parameter_type_info.pointer.child;
|
||||
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
|
||||
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
|
||||
if (slice_type == js.Value or (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8)) {
|
||||
is_variadic = true;
|
||||
if (js_parameter_count == 0) {
|
||||
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
|
||||
|
||||
@@ -23,9 +23,11 @@ const log = @import("../../log.zig");
|
||||
const js = @import("js.zig");
|
||||
const Env = @import("Env.zig");
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const ScriptManager = @import("../ScriptManager.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
@@ -41,6 +43,7 @@ const Context = @This();
|
||||
id: usize,
|
||||
env: *Env,
|
||||
page: *Page,
|
||||
session: *Session,
|
||||
isolate: js.Isolate,
|
||||
|
||||
// Per-context microtask queue for isolation between contexts
|
||||
@@ -74,39 +77,11 @@ call_depth: usize = 0,
|
||||
// context.localScope
|
||||
local: ?*const js.Local = null,
|
||||
|
||||
// Serves two purposes. Like `global_objects`, this is used to free
|
||||
// every Global(Object) we've created during the lifetime of the context.
|
||||
// More importantly, it serves as an identity map - for a given Zig
|
||||
// instance, we map it to the same Global(Object).
|
||||
// The key is the @intFromPtr of the Zig value
|
||||
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
origin: *Origin,
|
||||
|
||||
// Any type that is stored in the identity_map which has a finalizer declared
|
||||
// will have its finalizer stored here. This is only used when shutting down
|
||||
// if v8 hasn't called the finalizer directly itself.
|
||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
|
||||
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
|
||||
|
||||
// Some web APIs have to manage opaque values. Ideally, they use an
|
||||
// js.Object, but the js.Object has no lifetime guarantee beyond the
|
||||
// current call. They can call .persist() on their js.Object to get
|
||||
// a `Global(Object)`. We need to track these to free them.
|
||||
// This used to be a map and acted like identity_map; the key was
|
||||
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
|
||||
// a reliable way to know if an object has already been persisted,
|
||||
// we now simply persist every time persist() is called.
|
||||
global_values: std.ArrayList(v8.Global) = .empty,
|
||||
global_objects: std.ArrayList(v8.Global) = .empty,
|
||||
// Unlike other v8 types, like functions or objects, modules are not shared
|
||||
// across origins.
|
||||
global_modules: std.ArrayList(v8.Global) = .empty,
|
||||
global_promises: std.ArrayList(v8.Global) = .empty,
|
||||
global_functions: std.ArrayList(v8.Global) = .empty,
|
||||
global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
|
||||
|
||||
// Temp variants stored in HashMaps for O(1) early cleanup.
|
||||
// Key is global.data_ptr.
|
||||
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
|
||||
// Our module cache: normalized module specifier => module.
|
||||
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
|
||||
@@ -153,7 +128,7 @@ pub fn fromIsolate(isolate: js.Isolate) *Context {
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Context) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
if (comptime IS_DEBUG and @import("builtin").is_test == false) {
|
||||
var it = self.unknown_properties.iterator();
|
||||
while (it.next()) |kv| {
|
||||
log.debug(.unknown_prop, "unknown property", .{
|
||||
@@ -174,64 +149,11 @@ pub fn deinit(self: *Context) void {
|
||||
// this can release objects
|
||||
self.scheduler.deinit();
|
||||
|
||||
{
|
||||
var it = self.identity_map.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
{
|
||||
var it = self.finalizer_callbacks.valueIterator();
|
||||
while (it.next()) |finalizer| {
|
||||
finalizer.*.deinit();
|
||||
}
|
||||
self.finalizer_callback_pool.deinit();
|
||||
}
|
||||
|
||||
for (self.global_values.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
for (self.global_objects.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
for (self.global_modules.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
for (self.global_functions.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
for (self.global_promises.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
for (self.global_promise_resolvers.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.global_values_temp.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.global_promises_temp.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.global_functions_temp.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
self.session.releaseOrigin(self.origin);
|
||||
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
env.isolate.notifyContextDisposed();
|
||||
@@ -241,8 +163,40 @@ pub fn deinit(self: *Context) void {
|
||||
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
|
||||
}
|
||||
|
||||
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||
const env = self.env;
|
||||
const isolate = env.isolate;
|
||||
|
||||
const origin = try self.session.getOrCreateOrigin(key);
|
||||
errdefer self.session.releaseOrigin(origin);
|
||||
|
||||
try self.origin.transferTo(origin);
|
||||
self.origin.deinit(env.app);
|
||||
|
||||
self.origin = origin;
|
||||
|
||||
{
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
// Set the V8::Context SecurityToken, which is a big part of what allows
|
||||
// one context to access another.
|
||||
const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle);
|
||||
v8.v8__Context__SetSecurityToken(ls.local.handle, token_local);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
|
||||
return self.origin.trackGlobal(global);
|
||||
}
|
||||
|
||||
pub fn trackTemp(self: *Context, global: v8.Global) !void {
|
||||
return self.origin.trackTemp(global);
|
||||
}
|
||||
|
||||
pub fn weakRef(self: *Context, obj: anytype) void {
|
||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -253,7 +207,7 @@ pub fn weakRef(self: *Context, obj: anytype) void {
|
||||
}
|
||||
|
||||
pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -265,7 +219,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
||||
}
|
||||
|
||||
pub fn strongRef(self: *Context, obj: anytype) void {
|
||||
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -275,45 +229,6 @@ pub fn strongRef(self: *Context, obj: anytype) void {
|
||||
v8.v8__Global__ClearWeak(&fc.global);
|
||||
}
|
||||
|
||||
pub fn release(self: *Context, item: anytype) void {
|
||||
if (@TypeOf(item) == *anyopaque) {
|
||||
// Existing *anyopaque path for identity_map. Called internally from
|
||||
// finalizers
|
||||
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
}
|
||||
return;
|
||||
};
|
||||
v8.v8__Global__Reset(&global.value);
|
||||
|
||||
// The item has been fianalized, remove it for the finalizer callback so that
|
||||
// we don't try to call it again on shutdown.
|
||||
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.finalizer_callback_pool.destroy(fc.value);
|
||||
return;
|
||||
}
|
||||
|
||||
var map = switch (@TypeOf(item)) {
|
||||
js.Value.Temp => &self.global_values_temp,
|
||||
js.Promise.Temp => &self.global_promises_temp,
|
||||
js.Function.Temp => &self.global_functions_temp,
|
||||
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
|
||||
};
|
||||
|
||||
if (map.fetchRemove(item.handle.data_ptr)) |kv| {
|
||||
var global = kv.value;
|
||||
v8.v8__Global__Reset(&global);
|
||||
}
|
||||
}
|
||||
|
||||
// Any operation on the context have to be made from a local.
|
||||
pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
|
||||
const isolate = self.isolate;
|
||||
@@ -336,28 +251,18 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
|
||||
return l.toLocal(global);
|
||||
}
|
||||
|
||||
// This isn't expected to be called often. It's for converting attributes into
|
||||
// function calls, e.g. <body onload="doSomething"> will turn that "doSomething"
|
||||
// string into a js.Function which looks like: function(e) { doSomething(e) }
|
||||
// There might be more efficient ways to do this, but doing it this way means
|
||||
// our code only has to worry about js.Funtion, not some union of a js.Function
|
||||
// or a string.
|
||||
pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global {
|
||||
pub fn stringToPersistedFunction(
|
||||
self: *Context,
|
||||
function_body: []const u8,
|
||||
comptime parameter_names: []const []const u8,
|
||||
extensions: []const v8.Object,
|
||||
) !js.Function.Global {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
self.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var extra: []const u8 = "";
|
||||
const normalized = std.mem.trim(u8, str, &std.ascii.whitespace);
|
||||
if (normalized.len > 0 and normalized[normalized.len - 1] != ')') {
|
||||
extra = "(e)";
|
||||
}
|
||||
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
|
||||
const js_val = try ls.local.compileAndRun(full, null);
|
||||
if (!js_val.isFunction()) {
|
||||
return error.StringFunctionError;
|
||||
}
|
||||
return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist();
|
||||
const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions);
|
||||
return js_function.persist();
|
||||
}
|
||||
|
||||
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
|
||||
@@ -535,6 +440,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
|
||||
nested_gop.key_ptr.* = owned_specifier;
|
||||
nested_gop.value_ptr.* = .{};
|
||||
try script_manager.preloadImport(owned_specifier, url);
|
||||
} else if (nested_gop.value_ptr.module == null) {
|
||||
// Entry exists but module failed to compile previously.
|
||||
// The imported_modules entry may have been consumed, so
|
||||
// re-preload to ensure waitForImport can find it.
|
||||
// Key was stored via dupeZ so it has a sentinel in memory.
|
||||
const key = nested_gop.key_ptr.*;
|
||||
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
|
||||
try script_manager.preloadImport(key_z, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,7 +596,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
|
||||
return local.toLocal(m).handle;
|
||||
}
|
||||
|
||||
var source = try self.script_manager.?.waitForImport(normalized_specifier);
|
||||
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
|
||||
error.UnknownModule => blk: {
|
||||
// Module is in cache but was consumed from imported_modules
|
||||
// (e.g., by a previous failed resolution). Re-preload and retry.
|
||||
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
|
||||
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
defer source.deinit();
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
@@ -786,9 +707,16 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
|
||||
entry.module_promise = try module_resolver.promise().persist();
|
||||
} else {
|
||||
// the module was loaded, but not evaluated, we _have_ to evaluate it now
|
||||
if (status == .kUninstantiated) {
|
||||
if (try mod.instantiate(resolveModuleCallback) == false) {
|
||||
_ = resolver.reject("module instantiation", local.newString("Module instantiation failed"));
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
const evaluated = mod.evaluate() catch {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(status == .kErrored);
|
||||
std.debug.assert(mod.getStatus() == .kErrored);
|
||||
}
|
||||
_ = resolver.reject("module evaluation", local.newString("Module evaluation failed"));
|
||||
return promise;
|
||||
@@ -868,13 +796,12 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
|
||||
|
||||
const then_callback = newFunctionWithData(local, struct {
|
||||
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
||||
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
|
||||
var c: Caller = undefined;
|
||||
c.init(isolate);
|
||||
c.initFromHandle(callback_handle);
|
||||
defer c.deinit();
|
||||
|
||||
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
|
||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
|
||||
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
|
||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
|
||||
|
||||
if (s.context_id != c.local.ctx.id) {
|
||||
// The microtask is tied to the isolate, not the context
|
||||
@@ -893,17 +820,15 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
|
||||
|
||||
const catch_callback = newFunctionWithData(local, struct {
|
||||
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
||||
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
|
||||
var c: Caller = undefined;
|
||||
c.init(isolate);
|
||||
c.initFromHandle(callback_handle);
|
||||
defer c.deinit();
|
||||
|
||||
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
|
||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
|
||||
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
|
||||
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
|
||||
|
||||
const l = &c.local;
|
||||
const ctx = l.ctx;
|
||||
if (s.context_id != ctx.id) {
|
||||
if (s.context_id != l.ctx.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1007,39 +932,18 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
|
||||
}.run, self);
|
||||
}
|
||||
|
||||
// There's an assumption here: the js.Function will be alive when microtasks are
|
||||
// run. If we're Env.runMicrotasks in all the places that we're supposed to, then
|
||||
// this should be safe (I think). In whatever HandleScope a microtask is enqueued,
|
||||
// PerformCheckpoint should be run. So the v8::Local<v8::Function> should remain
|
||||
// valid. If we have problems with this, a simple solution is to provide a Zig
|
||||
// wrapper for these callbacks which references a js.Function.Temp, on callback
|
||||
// it executes the function and then releases the global.
|
||||
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
|
||||
// Use context-specific microtask queue instead of isolate queue
|
||||
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
|
||||
}
|
||||
|
||||
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback {
|
||||
const fc = try self.finalizer_callback_pool.create();
|
||||
fc.* = .{
|
||||
.ctx = self,
|
||||
.ptr = ptr,
|
||||
.global = global,
|
||||
.finalizerFn = finalizerFn,
|
||||
};
|
||||
return fc;
|
||||
}
|
||||
|
||||
// == Misc ==
|
||||
// A type that has a finalizer can have its finalizer called one of two ways.
|
||||
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
|
||||
// guaranteed to fire, so we track this in ctx._finalizers and call them on
|
||||
// context shutdown.
|
||||
pub const FinalizerCallback = struct {
|
||||
ctx: *Context,
|
||||
ptr: *anyopaque,
|
||||
global: v8.Global,
|
||||
finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void,
|
||||
|
||||
pub fn deinit(self: *FinalizerCallback) void {
|
||||
self.finalizerFn(self.ptr, self.ctx.page);
|
||||
self.ctx.finalizer_callback_pool.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
// == Profiler ==
|
||||
pub fn startCpuProfiler(self: *Context) void {
|
||||
if (comptime !IS_DEBUG) {
|
||||
|
||||
@@ -26,6 +26,7 @@ const App = @import("../../App.zig");
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Context = @import("Context.zig");
|
||||
const Isolate = @import("Isolate.zig");
|
||||
const Platform = @import("Platform.zig");
|
||||
@@ -57,6 +58,8 @@ const Env = @This();
|
||||
|
||||
app: *App,
|
||||
|
||||
allocator: Allocator,
|
||||
|
||||
platform: *const Platform,
|
||||
|
||||
// the global isolate
|
||||
@@ -70,6 +73,11 @@ isolate_params: *v8.CreateParams,
|
||||
|
||||
context_id: usize,
|
||||
|
||||
// Maps origin -> shared Origin contains, for v8 values shared across
|
||||
// same-origin Contexts. There's a mismatch here between our JS model and our
|
||||
// Browser model. Origins only live as long as the root page of a session exists.
|
||||
// It would be wrong/dangerous to re-use an Origin across root page navigations.
|
||||
|
||||
// Global handles that need to be freed on deinit
|
||||
eternal_function_templates: []v8.Eternal,
|
||||
|
||||
@@ -206,6 +214,7 @@ pub fn init(app: *App, opts: InitOpts) !Env {
|
||||
return .{
|
||||
.app = app,
|
||||
.context_id = 0,
|
||||
.allocator = allocator,
|
||||
.contexts = undefined,
|
||||
.context_count = 0,
|
||||
.isolate = isolate,
|
||||
@@ -228,7 +237,9 @@ pub fn deinit(self: *Env) void {
|
||||
ctx.deinit();
|
||||
}
|
||||
|
||||
const allocator = self.app.allocator;
|
||||
const app = self.app;
|
||||
const allocator = app.allocator;
|
||||
|
||||
if (self.inspector) |i| {
|
||||
i.deinit(allocator);
|
||||
}
|
||||
@@ -272,6 +283,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
|
||||
|
||||
// get the global object for the context, this maps to our Window
|
||||
const global_obj = v8.v8__Context__Global(v8_context).?;
|
||||
|
||||
{
|
||||
// Store our TAO inside the internal field of the global object. This
|
||||
// maps the v8::Object -> Zig instance. Almost all objects have this, and
|
||||
@@ -287,6 +299,7 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
|
||||
};
|
||||
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
|
||||
}
|
||||
|
||||
// our window wrapped in a v8::Global
|
||||
var global_global: v8.Global = undefined;
|
||||
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
|
||||
@@ -294,10 +307,15 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
|
||||
const context_id = self.context_id;
|
||||
self.context_id = context_id + 1;
|
||||
|
||||
const origin = try page._session.getOrCreateOrigin(null);
|
||||
errdefer page._session.releaseOrigin(origin);
|
||||
|
||||
const context = try context_arena.create(Context);
|
||||
context.* = .{
|
||||
.env = self,
|
||||
.page = page,
|
||||
.session = page._session,
|
||||
.origin = origin,
|
||||
.id = context_id,
|
||||
.isolate = isolate,
|
||||
.arena = context_arena,
|
||||
@@ -307,9 +325,8 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
|
||||
.microtask_queue = microtask_queue,
|
||||
.script_manager = &page._script_manager,
|
||||
.scheduler = .init(context_arena),
|
||||
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
|
||||
};
|
||||
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
|
||||
try context.origin.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
|
||||
|
||||
// Store a pointer to our context inside the v8 context so that, given
|
||||
// a v8 context, we can get our context out
|
||||
@@ -470,6 +487,10 @@ 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 terminate(self: *const Env) void {
|
||||
v8.v8__Isolate__TerminateExecution(self.isolate.handle);
|
||||
}
|
||||
|
||||
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
|
||||
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
|
||||
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
|
||||
|
||||
@@ -160,8 +160,8 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
|
||||
try_catch.rethrow();
|
||||
return error.TryCatchRethrow;
|
||||
}
|
||||
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
|
||||
return error.JSExecCallback;
|
||||
caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
|
||||
return error.JsException;
|
||||
};
|
||||
|
||||
if (@typeInfo(T) == .void) {
|
||||
@@ -209,11 +209,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.global_functions.append(ctx.arena, global);
|
||||
} else {
|
||||
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .origin = {} };
|
||||
}
|
||||
return .{ .handle = global };
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
}
|
||||
|
||||
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
|
||||
@@ -226,15 +226,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
|
||||
return with_this.persist();
|
||||
}
|
||||
|
||||
pub const Temp = G(0);
|
||||
pub const Global = G(1);
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
|
||||
fn G(comptime discriminator: u8) type {
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
|
||||
// makes the types different (G(0) != G(1)), without taking up space
|
||||
comptime _: u8 = discriminator,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -252,5 +255,9 @@ fn G(comptime discriminator: u8) type {
|
||||
pub fn isEqual(self: *const Self, other: Function) bool {
|
||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ pub fn contextCreated(
|
||||
|
||||
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
|
||||
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
|
||||
|
||||
if (self.default_context) |*dc| {
|
||||
if (v8.v8__Global__IsEqual(dc, context)) {
|
||||
self.default_context = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resetContextGroup(self: *const Inspector) void {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const log = @import("../../log.zig");
|
||||
const string = @import("../../string.zig");
|
||||
|
||||
@@ -82,6 +83,20 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s
|
||||
return .init(self, size);
|
||||
}
|
||||
|
||||
pub fn newCallback(
|
||||
self: *const Local,
|
||||
callback: anytype,
|
||||
data: anytype,
|
||||
) js.Function {
|
||||
const external = self.isolate.createExternal(data);
|
||||
const handle = v8.v8__Function__New__DEFAULT2(self.handle, struct {
|
||||
fn wrap(info_handle: ?*const js.v8.FunctionCallbackInfo) callconv(.c) void {
|
||||
Caller.Function.call(@TypeOf(data), info_handle.?, callback, .{ .embedded_receiver = true });
|
||||
}
|
||||
}.wrap, @ptrCast(external)).?;
|
||||
return .{ .local = self, .handle = handle };
|
||||
}
|
||||
|
||||
pub fn runMacrotasks(self: *const Local) void {
|
||||
const env = self.ctx.env;
|
||||
env.pumpMessageLoop();
|
||||
@@ -101,6 +116,49 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
|
||||
return self.compileAndRun(src, name);
|
||||
}
|
||||
|
||||
/// Compiles a function body as function.
|
||||
///
|
||||
/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a
|
||||
pub fn compileFunction(
|
||||
self: *const Local,
|
||||
function_body: []const u8,
|
||||
/// We tend to know how many params we'll pass; can remove the comptime if necessary.
|
||||
comptime parameter_names: []const []const u8,
|
||||
extensions: []const v8.Object,
|
||||
) !js.Function {
|
||||
// TODO: Make configurable.
|
||||
const script_name = self.isolate.initStringHandle("anonymous");
|
||||
const script_source = self.isolate.initStringHandle(function_body);
|
||||
|
||||
var parameter_list: [parameter_names.len]*const v8.String = undefined;
|
||||
inline for (0..parameter_names.len) |i| {
|
||||
parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]);
|
||||
}
|
||||
|
||||
// Create `ScriptOrigin`.
|
||||
var origin: v8.ScriptOrigin = undefined;
|
||||
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
|
||||
|
||||
// Create `ScriptCompilerSource`.
|
||||
var script_compiler_source: v8.ScriptCompilerSource = undefined;
|
||||
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source);
|
||||
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source);
|
||||
|
||||
// Compile the function.
|
||||
const result = v8.v8__ScriptCompiler__CompileFunction(
|
||||
self.handle,
|
||||
&script_compiler_source,
|
||||
parameter_list.len,
|
||||
¶meter_list,
|
||||
extensions.len,
|
||||
@ptrCast(&extensions),
|
||||
v8.kNoCompileOptions,
|
||||
v8.kNoCacheNoReason,
|
||||
) orelse return error.CompilationError;
|
||||
|
||||
return .{ .local = self, .handle = result };
|
||||
}
|
||||
|
||||
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
|
||||
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
|
||||
const script_source = self.isolate.initStringHandle(src);
|
||||
@@ -123,7 +181,7 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
|
||||
) orelse return error.CompilationError;
|
||||
|
||||
// Run the script
|
||||
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.ExecutionError;
|
||||
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.JsException;
|
||||
return .{ .local = self, .handle = result };
|
||||
}
|
||||
|
||||
@@ -157,7 +215,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
||||
.pointer => |ptr| {
|
||||
const resolved = resolveValue(value);
|
||||
|
||||
const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
|
||||
const gop = try ctx.origin.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
|
||||
if (gop.found_existing) {
|
||||
// we've seen this instance before, return the same object
|
||||
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
|
||||
@@ -211,16 +269,17 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
||||
// can't figure out how to make that work, since it depends on
|
||||
// the [runtime] `value`.
|
||||
// We need the resolved finalizer, which we have in resolved.
|
||||
//
|
||||
// The above if statement would be more clear as:
|
||||
// if (resolved.finalizer_from_v8) |finalizer| {
|
||||
// But that's a runtime check.
|
||||
// Instead, we check if the base has finalizer. The assumption
|
||||
// here is that if a resolve type has a finalizer, then the base
|
||||
// should have a finalizer too.
|
||||
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
||||
const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
||||
{
|
||||
errdefer fc.deinit();
|
||||
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
|
||||
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc);
|
||||
}
|
||||
|
||||
conditionallyReference(value);
|
||||
@@ -1069,7 +1128,7 @@ const Resolved = struct {
|
||||
class_id: u16,
|
||||
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
|
||||
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
|
||||
finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null,
|
||||
finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null,
|
||||
};
|
||||
pub fn resolveValue(value: anytype) Resolved {
|
||||
const T = bridge.Struct(@TypeOf(value));
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global {
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
|
||||
try ctx.global_objects.append(ctx.arena, global);
|
||||
try ctx.trackGlobal(global);
|
||||
|
||||
return .{ .handle = global };
|
||||
}
|
||||
|
||||
240
src/browser/js/Origin.zig
Normal file
240
src/browser/js/Origin.zig
Normal file
@@ -0,0 +1,240 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Origin represents the shared Zig<->JS bridge state for all contexts within
|
||||
// the same origin. Multiple contexts (frames) from the same origin share a
|
||||
// single Origin, ensuring that JS objects maintain their identity across frames.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
|
||||
const App = @import("../../App.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Origin = @This();
|
||||
|
||||
rc: usize = 1,
|
||||
arena: Allocator,
|
||||
|
||||
// The key, e.g. lightpanda.io:443
|
||||
key: []const u8,
|
||||
|
||||
// Security token - all contexts in this realm must use the same v8::Value instance
|
||||
// as their security token for V8 to allow cross-context access
|
||||
security_token: v8.Global,
|
||||
|
||||
// Serves two purposes. Like `global_objects`, this is used to free
|
||||
// every Global(Object) we've created during the lifetime of the realm.
|
||||
// More importantly, it serves as an identity map - for a given Zig
|
||||
// instance, we map it to the same Global(Object).
|
||||
// The key is the @intFromPtr of the Zig value
|
||||
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
|
||||
// Some web APIs have to manage opaque values. Ideally, they use an
|
||||
// js.Object, but the js.Object has no lifetime guarantee beyond the
|
||||
// current call. They can call .persist() on their js.Object to get
|
||||
// a `Global(Object)`. We need to track these to free them.
|
||||
// This used to be a map and acted like identity_map; the key was
|
||||
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
|
||||
// a reliable way to know if an object has already been persisted,
|
||||
// we now simply persist every time persist() is called.
|
||||
globals: std.ArrayList(v8.Global) = .empty,
|
||||
|
||||
// Temp variants stored in HashMaps for O(1) early cleanup.
|
||||
// Key is global.data_ptr.
|
||||
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
|
||||
// Any type that is stored in the identity_map which has a finalizer declared
|
||||
// will have its finalizer stored here. This is only used when shutting down
|
||||
// if v8 hasn't called the finalizer directly itself.
|
||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
|
||||
|
||||
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
|
||||
const arena = try app.arena_pool.acquire();
|
||||
errdefer app.arena_pool.release(arena);
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
hs.init(isolate);
|
||||
defer hs.deinit();
|
||||
|
||||
const owned_key = try arena.dupe(u8, key);
|
||||
const token_local = isolate.initStringHandle(owned_key);
|
||||
var token_global: v8.Global = undefined;
|
||||
v8.v8__Global__New(isolate.handle, token_local, &token_global);
|
||||
|
||||
const self = try arena.create(Origin);
|
||||
self.* = .{
|
||||
.rc = 1,
|
||||
.arena = arena,
|
||||
.key = owned_key,
|
||||
.globals = .empty,
|
||||
.temps = .empty,
|
||||
.security_token = token_global,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Origin, app: *App) void {
|
||||
// Call finalizers before releasing anything
|
||||
{
|
||||
var it = self.finalizer_callbacks.valueIterator();
|
||||
while (it.next()) |finalizer| {
|
||||
finalizer.*.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
v8.v8__Global__Reset(&self.security_token);
|
||||
|
||||
{
|
||||
var it = self.identity_map.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
|
||||
for (self.globals.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.temps.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
|
||||
app.arena_pool.release(self.arena);
|
||||
}
|
||||
|
||||
pub fn trackGlobal(self: *Origin, global: v8.Global) !void {
|
||||
return self.globals.append(self.arena, global);
|
||||
}
|
||||
|
||||
pub fn trackTemp(self: *Origin, global: v8.Global) !void {
|
||||
return self.temps.put(self.arena, global.data_ptr, global);
|
||||
}
|
||||
|
||||
pub fn releaseTemp(self: *Origin, global: v8.Global) void {
|
||||
if (self.temps.fetchRemove(global.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
|
||||
/// Release an item from the identity_map (called after finalizer runs from V8)
|
||||
pub fn release(self: *Origin, item: *anyopaque) void {
|
||||
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(false);
|
||||
}
|
||||
return;
|
||||
};
|
||||
v8.v8__Global__Reset(&global.value);
|
||||
|
||||
// The item has been finalized, remove it from the finalizer callback so that
|
||||
// we don't try to call it again on shutdown.
|
||||
const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(false);
|
||||
}
|
||||
return;
|
||||
};
|
||||
const fc = kv.value;
|
||||
fc.session.releaseArena(fc.arena);
|
||||
}
|
||||
|
||||
pub fn createFinalizerCallback(
|
||||
self: *Origin,
|
||||
session: *Session,
|
||||
global: v8.Global,
|
||||
ptr: *anyopaque,
|
||||
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
|
||||
) !*FinalizerCallback {
|
||||
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
|
||||
errdefer session.releaseArena(arena);
|
||||
const fc = try arena.create(FinalizerCallback);
|
||||
fc.* = .{
|
||||
.arena = arena,
|
||||
.origin = self,
|
||||
.session = session,
|
||||
.ptr = ptr,
|
||||
.global = global,
|
||||
.zig_finalizer = zig_finalizer,
|
||||
};
|
||||
return fc;
|
||||
}
|
||||
|
||||
pub fn transferTo(self: *Origin, dest: *Origin) !void {
|
||||
const arena = dest.arena;
|
||||
|
||||
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
|
||||
for (self.globals.items) |obj| {
|
||||
dest.globals.appendAssumeCapacity(obj);
|
||||
}
|
||||
self.globals.clearRetainingCapacity();
|
||||
|
||||
{
|
||||
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
|
||||
var it = self.temps.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.temps.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
|
||||
var it = self.finalizer_callbacks.iterator();
|
||||
while (it.next()) |kv| {
|
||||
kv.value_ptr.*.origin = dest;
|
||||
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.finalizer_callbacks.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
|
||||
var it = self.identity_map.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.identity_map.clearRetainingCapacity();
|
||||
}
|
||||
}
|
||||
|
||||
// A type that has a finalizer can have its finalizer called one of two ways.
|
||||
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
|
||||
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
|
||||
// origin shutdown.
|
||||
pub const FinalizerCallback = struct {
|
||||
arena: Allocator,
|
||||
origin: *Origin,
|
||||
session: *Session,
|
||||
ptr: *anyopaque,
|
||||
global: v8.Global,
|
||||
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
|
||||
|
||||
pub fn deinit(self: *FinalizerCallback) void {
|
||||
self.zig_finalizer(self.ptr, self.session);
|
||||
self.session.releaseArena(self.arena);
|
||||
}
|
||||
};
|
||||
@@ -62,22 +62,25 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.global_promises.append(ctx.arena, global);
|
||||
} else {
|
||||
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .origin = {} };
|
||||
}
|
||||
return .{ .handle = global };
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
}
|
||||
|
||||
pub const Temp = G(0);
|
||||
pub const Global = G(1);
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
|
||||
fn G(comptime discriminator: u8) type {
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
|
||||
// makes the types different (G(0) != G(1)), without taking up space
|
||||
comptime _: u8 = discriminator,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -91,5 +94,9 @@ fn G(comptime discriminator: u8) type {
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn persist(self: PromiseResolver) !Global {
|
||||
var ctx = self.local.ctx;
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
try ctx.global_promise_resolvers.append(ctx.arena, global);
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global };
|
||||
}
|
||||
|
||||
|
||||
@@ -259,11 +259,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.global_values.append(ctx.arena, global);
|
||||
} else {
|
||||
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .origin = {} };
|
||||
}
|
||||
return .{ .handle = global };
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
}
|
||||
|
||||
pub fn toZig(self: Value, comptime T: type) !T {
|
||||
@@ -310,15 +310,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
|
||||
return js_str.format(writer);
|
||||
}
|
||||
|
||||
pub const Temp = G(0);
|
||||
pub const Global = G(1);
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
|
||||
fn G(comptime discriminator: u8) type {
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
|
||||
// makes the types different (G(0) != G(1)), without taking up space
|
||||
comptime _: u8 = discriminator,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -336,5 +339,9 @@ fn G(comptime discriminator: u8) type {
|
||||
pub fn isEqual(self: *const Self, other: Value) bool {
|
||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ const js = @import("js.zig");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../log.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
|
||||
const Caller = @import("Caller.zig");
|
||||
const Context = @import("Context.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
@@ -104,24 +106,24 @@ pub fn Builder(comptime T: type) type {
|
||||
return entries;
|
||||
}
|
||||
|
||||
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer {
|
||||
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer {
|
||||
return .{
|
||||
.from_zig = struct {
|
||||
fn wrap(ptr: *anyopaque, page: *Page) void {
|
||||
func(@ptrCast(@alignCast(ptr)), true, page);
|
||||
fn wrap(ptr: *anyopaque, session: *Session) void {
|
||||
func(@ptrCast(@alignCast(ptr)), true, session);
|
||||
}
|
||||
}.wrap,
|
||||
|
||||
.from_v8 = struct {
|
||||
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
|
||||
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
|
||||
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
||||
const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const ctx = fc.ctx;
|
||||
const origin = fc.origin;
|
||||
const value_ptr = fc.ptr;
|
||||
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
||||
func(@ptrCast(@alignCast(value_ptr)), false, ctx.page);
|
||||
ctx.release(value_ptr);
|
||||
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
||||
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
|
||||
origin.release(value_ptr);
|
||||
} else {
|
||||
// A bit weird, but v8 _requires_ that we release it
|
||||
// If we don't. We'll 100% crash.
|
||||
@@ -413,12 +415,12 @@ pub const Property = struct {
|
||||
};
|
||||
|
||||
const Finalizer = struct {
|
||||
// The finalizer wrapper when called fro Zig. This is only called on
|
||||
// Context.deinit
|
||||
from_zig: *const fn (ctx: *anyopaque, page: *Page) void,
|
||||
// The finalizer wrapper when called from Zig. This is only called on
|
||||
// Origin.deinit
|
||||
from_zig: *const fn (ctx: *anyopaque, session: *Session) void,
|
||||
|
||||
// The finalizer wrapper when called from V8. This may never be called
|
||||
// (hence why we fallback to calling in Context.denit). If it is called,
|
||||
// (hence why we fallback to calling in Origin.deinit). If it is called,
|
||||
// it is only ever called after we SetWeak on the Global.
|
||||
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
|
||||
};
|
||||
@@ -730,6 +732,7 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/css/CSSStyleRule.zig"),
|
||||
@import("../webapi/css/CSSStyleSheet.zig"),
|
||||
@import("../webapi/css/CSSStyleProperties.zig"),
|
||||
@import("../webapi/css/FontFace.zig"),
|
||||
@import("../webapi/css/FontFaceSet.zig"),
|
||||
@import("../webapi/css/MediaQueryList.zig"),
|
||||
@import("../webapi/css/StyleSheetList.zig"),
|
||||
@@ -767,6 +770,7 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/element/html/Custom.zig"),
|
||||
@import("../webapi/element/html/Data.zig"),
|
||||
@import("../webapi/element/html/DataList.zig"),
|
||||
@import("../webapi/element/html/Details.zig"),
|
||||
@import("../webapi/element/html/Dialog.zig"),
|
||||
@import("../webapi/element/html/Directory.zig"),
|
||||
@import("../webapi/element/html/DList.zig"),
|
||||
@@ -826,6 +830,8 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/element/svg/Generic.zig"),
|
||||
@import("../webapi/encoding/TextDecoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoderStream.zig"),
|
||||
@import("../webapi/encoding/TextDecoderStream.zig"),
|
||||
@import("../webapi/Event.zig"),
|
||||
@import("../webapi/event/CompositionEvent.zig"),
|
||||
@import("../webapi/event/CustomEvent.zig"),
|
||||
@@ -862,6 +868,10 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/streams/ReadableStream.zig"),
|
||||
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
|
||||
@import("../webapi/streams/ReadableStreamDefaultController.zig"),
|
||||
@import("../webapi/streams/WritableStream.zig"),
|
||||
@import("../webapi/streams/WritableStreamDefaultWriter.zig"),
|
||||
@import("../webapi/streams/WritableStreamDefaultController.zig"),
|
||||
@import("../webapi/streams/TransformStream.zig"),
|
||||
@import("../webapi/Node.zig"),
|
||||
@import("../webapi/storage/storage.zig"),
|
||||
@import("../webapi/URL.zig"),
|
||||
|
||||
@@ -24,6 +24,7 @@ const string = @import("../../string.zig");
|
||||
pub const Env = @import("Env.zig");
|
||||
pub const bridge = @import("bridge.zig");
|
||||
pub const Caller = @import("Caller.zig");
|
||||
pub const Origin = @import("Origin.zig");
|
||||
pub const Context = @import("Context.zig");
|
||||
pub const Local = @import("Local.zig");
|
||||
pub const Inspector = @import("Inspector.zig");
|
||||
@@ -161,7 +162,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
|
||||
var ctx = self.local.ctx;
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
try ctx.global_values.append(ctx.arena, global);
|
||||
try ctx.trackGlobal(global);
|
||||
|
||||
return .{ .handle = global };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const CData = @import("webapi/CData.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
@@ -103,20 +105,37 @@ fn isVisibleElement(el: *Element) bool {
|
||||
};
|
||||
}
|
||||
|
||||
fn getAnchorLabel(el: *Element) ?[]const u8 {
|
||||
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
|
||||
}
|
||||
|
||||
fn isAllWhitespace(text: []const u8) bool {
|
||||
return for (text) |c| {
|
||||
if (!std.ascii.isWhitespace(c)) break false;
|
||||
} else true;
|
||||
}
|
||||
|
||||
fn hasBlockDescendant(node: *Node) bool {
|
||||
var it = node.childrenIterator();
|
||||
return while (it.next()) |child| {
|
||||
if (child.is(Element)) |el| {
|
||||
if (isBlock(el.getTag())) break true;
|
||||
if (hasBlockDescendant(child)) break true;
|
||||
fn hasBlockDescendant(root: *Node) bool {
|
||||
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
|
||||
while (tw.next()) |el| {
|
||||
if (isBlock(el.getTag())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hasVisibleContent(root: *Node) bool {
|
||||
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
|
||||
while (tw.next()) |node| {
|
||||
if (isSignificantText(node)) return true;
|
||||
if (node.is(Element)) |el| {
|
||||
if (!isVisibleElement(el)) {
|
||||
tw.skipChildren();
|
||||
} else if (el.getTag() == .img) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
|
||||
@@ -278,20 +297,29 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
|
||||
}
|
||||
try writer.writeAll("](");
|
||||
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||
try writer.writeAll(src);
|
||||
const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
|
||||
try writer.writeAll(absolute_src);
|
||||
}
|
||||
try writer.writeAll(")");
|
||||
state.last_char_was_newline = false;
|
||||
return;
|
||||
},
|
||||
.anchor => {
|
||||
const has_content = hasVisibleContent(el.asNode());
|
||||
const label = getAnchorLabel(el);
|
||||
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
|
||||
|
||||
if (!has_content and label == null and href_raw == null) return;
|
||||
|
||||
const has_block = hasBlockDescendant(el.asNode());
|
||||
const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
|
||||
|
||||
if (has_block) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
|
||||
if (href) |h| {
|
||||
if (!state.last_char_was_newline) try writer.writeByte('\n');
|
||||
try writer.writeAll("([Link](");
|
||||
try writer.writeAll(href);
|
||||
try writer.writeAll("([](");
|
||||
try writer.writeAll(h);
|
||||
try writer.writeAll("))\n");
|
||||
state.last_char_was_newline = true;
|
||||
}
|
||||
@@ -301,10 +329,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
|
||||
if (isStandaloneAnchor(el)) {
|
||||
if (!state.last_char_was_newline) try writer.writeByte('\n');
|
||||
try writer.writeByte('[');
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
if (has_content) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
} else {
|
||||
try writer.writeAll(label orelse "");
|
||||
}
|
||||
try writer.writeAll("](");
|
||||
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
|
||||
try writer.writeAll(href);
|
||||
if (href) |h| {
|
||||
try writer.writeAll(h);
|
||||
}
|
||||
try writer.writeAll(")\n");
|
||||
state.last_char_was_newline = true;
|
||||
@@ -312,10 +344,14 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag
|
||||
}
|
||||
|
||||
try writer.writeByte('[');
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
if (has_content) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
} else {
|
||||
try writer.writeAll(label orelse "");
|
||||
}
|
||||
try writer.writeAll("](");
|
||||
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
|
||||
try writer.writeAll(href);
|
||||
if (href) |h| {
|
||||
try writer.writeAll(h);
|
||||
}
|
||||
try writer.writeByte(')');
|
||||
state.last_char_was_newline = false;
|
||||
@@ -452,6 +488,8 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
|
||||
const testing = @import("../testing.zig");
|
||||
const page = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
page.url = "http://localhost/";
|
||||
|
||||
const doc = page.window._document;
|
||||
|
||||
const div = try doc.createElement("div", null, page);
|
||||
@@ -520,11 +558,11 @@ test "browser.markdown: blockquote" {
|
||||
}
|
||||
|
||||
test "browser.markdown: links" {
|
||||
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
|
||||
try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
|
||||
}
|
||||
|
||||
test "browser.markdown: images" {
|
||||
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "\n");
|
||||
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "\n");
|
||||
}
|
||||
|
||||
test "browser.markdown: headings" {
|
||||
@@ -565,7 +603,7 @@ test "browser.markdown: block link" {
|
||||
\\### Title
|
||||
\\
|
||||
\\Description
|
||||
\\([Link](https://example.com))
|
||||
\\([](https://example.com))
|
||||
\\
|
||||
);
|
||||
}
|
||||
@@ -588,8 +626,8 @@ test "browser.markdown: standalone anchors" {
|
||||
\\ <a href="2">Link 2</a>
|
||||
\\</main>
|
||||
,
|
||||
\\[Link 1](1)
|
||||
\\[Link 2](2)
|
||||
\\[Link 1](http://localhost/1)
|
||||
\\[Link 2](http://localhost/2)
|
||||
\\
|
||||
);
|
||||
}
|
||||
@@ -601,7 +639,58 @@ test "browser.markdown: mixed anchors in main" {
|
||||
\\ Welcome <a href="1">Link 1</a>.
|
||||
\\</main>
|
||||
,
|
||||
\\Welcome [Link 1](1).
|
||||
\\Welcome [Link 1](http://localhost/1).
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "browser.markdown: skip empty links" {
|
||||
try testMarkdownHTML(
|
||||
\\<a href="/"></a>
|
||||
\\<a href="/"><svg></svg></a>
|
||||
,
|
||||
\\[](http://localhost/)
|
||||
\\[](http://localhost/)
|
||||
\\
|
||||
);
|
||||
}
|
||||
|
||||
test "browser.markdown: resolve links" {
|
||||
const testing = @import("../testing.zig");
|
||||
const page = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
page.url = "https://example.com/a/index.html";
|
||||
|
||||
const doc = page.window._document;
|
||||
const div = try doc.createElement("div", null, page);
|
||||
try page.parseHtmlAsChildren(div.asNode(),
|
||||
\\<a href="b">Link</a>
|
||||
\\<img src="../c.png" alt="Img">
|
||||
\\<a href="/my page">Space</a>
|
||||
);
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
try dump(div.asNode(), .{}, &aw.writer, page);
|
||||
|
||||
try testing.expectString(
|
||||
\\[Link](https://example.com/a/b)
|
||||
\\
|
||||
\\[Space](https://example.com/my%20page)
|
||||
\\
|
||||
, aw.written());
|
||||
}
|
||||
|
||||
test "browser.markdown: anchor fallback label" {
|
||||
try testMarkdownHTML(
|
||||
\\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
|
||||
, "[Discord Server](http://localhost/discord)\n");
|
||||
|
||||
try testMarkdownHTML(
|
||||
\\<a href="/search" title="Search Site"><svg></svg></a>
|
||||
, "[Search Site](http://localhost/search)\n");
|
||||
|
||||
try testMarkdownHTML(
|
||||
\\<a href="/no-label"><svg></svg></a>
|
||||
, "[](http://localhost/no-label)\n");
|
||||
}
|
||||
|
||||
489
src/browser/structured_data.zig
Normal file
489
src/browser/structured_data.zig
Normal file
@@ -0,0 +1,489 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Key-value pair for structured data properties.
|
||||
pub const Property = struct {
|
||||
key: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub const AlternateLink = struct {
|
||||
href: []const u8,
|
||||
hreflang: ?[]const u8,
|
||||
type: ?[]const u8,
|
||||
title: ?[]const u8,
|
||||
};
|
||||
|
||||
pub const StructuredData = struct {
|
||||
json_ld: []const []const u8,
|
||||
open_graph: []const Property,
|
||||
twitter_card: []const Property,
|
||||
meta: []const Property,
|
||||
links: []const Property,
|
||||
alternate: []const AlternateLink,
|
||||
|
||||
pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
|
||||
try jw.objectField("jsonLd");
|
||||
try jw.write(self.json_ld);
|
||||
|
||||
try jw.objectField("openGraph");
|
||||
try writeProperties(jw, self.open_graph);
|
||||
|
||||
try jw.objectField("twitterCard");
|
||||
try writeProperties(jw, self.twitter_card);
|
||||
|
||||
try jw.objectField("meta");
|
||||
try writeProperties(jw, self.meta);
|
||||
|
||||
try jw.objectField("links");
|
||||
try writeProperties(jw, self.links);
|
||||
|
||||
if (self.alternate.len > 0) {
|
||||
try jw.objectField("alternate");
|
||||
try jw.beginArray();
|
||||
for (self.alternate) |alt| {
|
||||
try jw.beginObject();
|
||||
try jw.objectField("href");
|
||||
try jw.write(alt.href);
|
||||
if (alt.hreflang) |v| {
|
||||
try jw.objectField("hreflang");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (alt.type) |v| {
|
||||
try jw.objectField("type");
|
||||
try jw.write(v);
|
||||
}
|
||||
if (alt.title) |v| {
|
||||
try jw.objectField("title");
|
||||
try jw.write(v);
|
||||
}
|
||||
try jw.endObject();
|
||||
}
|
||||
try jw.endArray();
|
||||
}
|
||||
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
/// Serializes properties as a JSON object. When a key appears multiple times
|
||||
/// (e.g. multiple og:image tags), values are grouped into an array.
|
||||
/// Alternatives considered: always-array values (verbose), or an array of
|
||||
/// {key, value} pairs (preserves order but less ergonomic for consumers).
|
||||
fn writeProperties(jw: anytype, properties: []const Property) !void {
|
||||
try jw.beginObject();
|
||||
for (properties, 0..) |prop, i| {
|
||||
// Skip keys already written by an earlier occurrence.
|
||||
var already_written = false;
|
||||
for (properties[0..i]) |prev| {
|
||||
if (std.mem.eql(u8, prev.key, prop.key)) {
|
||||
already_written = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (already_written) continue;
|
||||
|
||||
// Count total occurrences to decide string vs array.
|
||||
var count: usize = 0;
|
||||
for (properties) |p| {
|
||||
if (std.mem.eql(u8, p.key, prop.key)) count += 1;
|
||||
}
|
||||
|
||||
try jw.objectField(prop.key);
|
||||
if (count == 1) {
|
||||
try jw.write(prop.value);
|
||||
} else {
|
||||
try jw.beginArray();
|
||||
for (properties) |p| {
|
||||
if (std.mem.eql(u8, p.key, prop.key)) {
|
||||
try jw.write(p.value);
|
||||
}
|
||||
}
|
||||
try jw.endArray();
|
||||
}
|
||||
}
|
||||
try jw.endObject();
|
||||
}
|
||||
|
||||
/// Extract all structured data from the page.
|
||||
pub fn collectStructuredData(
|
||||
root: *Node,
|
||||
arena: Allocator,
|
||||
page: *Page,
|
||||
) !StructuredData {
|
||||
var json_ld: std.ArrayList([]const u8) = .empty;
|
||||
var open_graph: std.ArrayList(Property) = .empty;
|
||||
var twitter_card: std.ArrayList(Property) = .empty;
|
||||
var meta: std.ArrayList(Property) = .empty;
|
||||
var links: std.ArrayList(Property) = .empty;
|
||||
var alternate: std.ArrayList(AlternateLink) = .empty;
|
||||
|
||||
// Extract language from the root <html> element.
|
||||
if (root.is(Element)) |root_el| {
|
||||
if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
|
||||
try meta.append(arena, .{ .key = "language", .value = lang });
|
||||
}
|
||||
} else {
|
||||
// Root is document — check documentElement.
|
||||
var children = root.childrenIterator();
|
||||
while (children.next()) |child| {
|
||||
const el = child.is(Element) orelse continue;
|
||||
if (el.getTag() == .html) {
|
||||
if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
|
||||
try meta.append(arena, .{ .key = "language", .value = lang });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tw = TreeWalker.Full.init(root, .{});
|
||||
while (tw.next()) |node| {
|
||||
const el = node.is(Element) orelse continue;
|
||||
|
||||
switch (el.getTag()) {
|
||||
.script => {
|
||||
try collectJsonLd(el, arena, &json_ld);
|
||||
tw.skipChildren();
|
||||
},
|
||||
.meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {},
|
||||
.title => try collectTitle(node, arena, &meta),
|
||||
.link => try collectLink(el, arena, page, &links, &alternate),
|
||||
// Skip body subtree for non-JSON-LD — all other metadata is in <head>.
|
||||
// JSON-LD can appear in <body> so we don't skip the whole body.
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.json_ld = json_ld.items,
|
||||
.open_graph = open_graph.items,
|
||||
.twitter_card = twitter_card.items,
|
||||
.meta = meta.items,
|
||||
.links = links.items,
|
||||
.alternate = alternate.items,
|
||||
};
|
||||
}
|
||||
|
||||
fn collectJsonLd(
|
||||
el: *Element,
|
||||
arena: Allocator,
|
||||
json_ld: *std.ArrayList([]const u8),
|
||||
) !void {
|
||||
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
|
||||
if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return;
|
||||
|
||||
var buf: std.Io.Writer.Allocating = .init(arena);
|
||||
try el.asNode().getTextContent(&buf.writer);
|
||||
const text = buf.written();
|
||||
if (text.len > 0) {
|
||||
try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace));
|
||||
}
|
||||
}
|
||||
|
||||
fn collectMeta(
|
||||
el: *Element,
|
||||
open_graph: *std.ArrayList(Property),
|
||||
twitter_card: *std.ArrayList(Property),
|
||||
meta: *std.ArrayList(Property),
|
||||
arena: Allocator,
|
||||
) !void {
|
||||
// charset: <meta charset="..."> (no content attribute needed).
|
||||
if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| {
|
||||
try meta.append(arena, .{ .key = "charset", .value = charset });
|
||||
}
|
||||
|
||||
const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
|
||||
|
||||
// Open Graph: <meta property="og:...">
|
||||
if (el.getAttributeSafe(comptime .wrap("property"))) |property| {
|
||||
if (std.mem.startsWith(u8, property, "og:")) {
|
||||
try open_graph.append(arena, .{ .key = property[3..], .value = content });
|
||||
return;
|
||||
}
|
||||
// Article, profile, etc. are OG sub-namespaces.
|
||||
if (std.mem.startsWith(u8, property, "article:") or
|
||||
std.mem.startsWith(u8, property, "profile:") or
|
||||
std.mem.startsWith(u8, property, "book:") or
|
||||
std.mem.startsWith(u8, property, "music:") or
|
||||
std.mem.startsWith(u8, property, "video:"))
|
||||
{
|
||||
try open_graph.append(arena, .{ .key = property, .value = content });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter Cards: <meta name="twitter:...">
|
||||
if (el.getAttributeSafe(comptime .wrap("name"))) |name| {
|
||||
if (std.mem.startsWith(u8, name, "twitter:")) {
|
||||
try twitter_card.append(arena, .{ .key = name[8..], .value = content });
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard meta tags by name.
|
||||
const known_names = [_][]const u8{
|
||||
"description", "author", "keywords", "robots",
|
||||
"viewport", "generator", "theme-color",
|
||||
};
|
||||
for (known_names) |known| {
|
||||
if (std.ascii.eqlIgnoreCase(name, known)) {
|
||||
try meta.append(arena, .{ .key = known, .value = content });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// http-equiv (e.g. Content-Type, refresh)
|
||||
if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| {
|
||||
try meta.append(arena, .{ .key = http_equiv, .value = content });
|
||||
}
|
||||
}
|
||||
|
||||
fn collectTitle(
|
||||
node: *Node,
|
||||
arena: Allocator,
|
||||
meta: *std.ArrayList(Property),
|
||||
) !void {
|
||||
var buf: std.Io.Writer.Allocating = .init(arena);
|
||||
try node.getTextContent(&buf.writer);
|
||||
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
|
||||
if (text.len > 0) {
|
||||
try meta.append(arena, .{ .key = "title", .value = text });
|
||||
}
|
||||
}
|
||||
|
||||
fn collectLink(
|
||||
el: *Element,
|
||||
arena: Allocator,
|
||||
page: *Page,
|
||||
links: *std.ArrayList(Property),
|
||||
alternate: *std.ArrayList(AlternateLink),
|
||||
) !void {
|
||||
const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return;
|
||||
const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return;
|
||||
const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href;
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(rel, "alternate")) {
|
||||
try alternate.append(arena, .{
|
||||
.href = href,
|
||||
.hreflang = el.getAttributeSafe(comptime .wrap("hreflang")),
|
||||
.type = el.getAttributeSafe(comptime .wrap("type")),
|
||||
.title = el.getAttributeSafe(comptime .wrap("title")),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const relevant_rels = [_][]const u8{
|
||||
"canonical", "icon", "manifest", "shortcut icon",
|
||||
"apple-touch-icon", "search", "author", "license",
|
||||
"dns-prefetch", "preconnect",
|
||||
};
|
||||
for (relevant_rels) |known| {
|
||||
if (std.ascii.eqlIgnoreCase(rel, known)) {
|
||||
try links.append(arena, .{ .key = known, .value = href });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn testStructuredData(html: []const u8) !StructuredData {
|
||||
const page = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
|
||||
const doc = page.window._document;
|
||||
const div = try doc.createElement("div", null, page);
|
||||
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||
|
||||
return collectStructuredData(div.asNode(), page.call_arena, page);
|
||||
}
|
||||
|
||||
fn findProperty(props: []const Property, key: []const u8) ?[]const u8 {
|
||||
for (props) |p| {
|
||||
if (std.mem.eql(u8, p.key, key)) return p.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test "structured_data: json-ld" {
|
||||
const data = try testStructuredData(
|
||||
\\<script type="application/ld+json">
|
||||
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
|
||||
\\</script>
|
||||
);
|
||||
try testing.expectEqual(1, data.json_ld.len);
|
||||
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
|
||||
}
|
||||
|
||||
test "structured_data: multiple json-ld" {
|
||||
const data = try testStructuredData(
|
||||
\\<script type="application/ld+json">{"@type":"Organization"}</script>
|
||||
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
|
||||
\\<script type="text/javascript">var x = 1;</script>
|
||||
);
|
||||
try testing.expectEqual(2, data.json_ld.len);
|
||||
}
|
||||
|
||||
test "structured_data: open graph" {
|
||||
const data = try testStructuredData(
|
||||
\\<meta property="og:title" content="My Page">
|
||||
\\<meta property="og:description" content="A description">
|
||||
\\<meta property="og:image" content="https://example.com/img.jpg">
|
||||
\\<meta property="og:url" content="https://example.com">
|
||||
\\<meta property="og:type" content="article">
|
||||
\\<meta property="article:published_time" content="2026-03-10">
|
||||
);
|
||||
try testing.expectEqual(6, data.open_graph.len);
|
||||
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
|
||||
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
|
||||
try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?);
|
||||
}
|
||||
|
||||
test "structured_data: open graph duplicate keys" {
|
||||
const data = try testStructuredData(
|
||||
\\<meta property="og:title" content="My Page">
|
||||
\\<meta property="og:image" content="https://example.com/img1.jpg">
|
||||
\\<meta property="og:image" content="https://example.com/img2.jpg">
|
||||
\\<meta property="og:image" content="https://example.com/img3.jpg">
|
||||
);
|
||||
// Duplicate keys are preserved as separate Property entries.
|
||||
try testing.expectEqual(4, data.open_graph.len);
|
||||
|
||||
// Verify serialization groups duplicates into arrays.
|
||||
const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{});
|
||||
defer testing.allocator.free(json);
|
||||
|
||||
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
|
||||
defer parsed.deinit();
|
||||
const og = parsed.value.object.get("openGraph").?.object;
|
||||
// "title" appears once → string.
|
||||
switch (og.get("title").?) {
|
||||
.string => {},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
// "image" appears 3 times → array.
|
||||
switch (og.get("image").?) {
|
||||
.array => |arr| try testing.expectEqual(3, arr.items.len),
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "structured_data: twitter card" {
|
||||
const data = try testStructuredData(
|
||||
\\<meta name="twitter:card" content="summary_large_image">
|
||||
\\<meta name="twitter:site" content="@example">
|
||||
\\<meta name="twitter:title" content="My Page">
|
||||
);
|
||||
try testing.expectEqual(3, data.twitter_card.len);
|
||||
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
|
||||
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
|
||||
}
|
||||
|
||||
test "structured_data: meta tags" {
|
||||
const data = try testStructuredData(
|
||||
\\<title>Page Title</title>
|
||||
\\<meta name="description" content="A test page">
|
||||
\\<meta name="author" content="Test Author">
|
||||
\\<meta name="keywords" content="test, example">
|
||||
\\<meta name="robots" content="index, follow">
|
||||
);
|
||||
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
|
||||
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
|
||||
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
|
||||
try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?);
|
||||
try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?);
|
||||
}
|
||||
|
||||
test "structured_data: link elements" {
|
||||
const data = try testStructuredData(
|
||||
\\<link rel="canonical" href="https://example.com/page">
|
||||
\\<link rel="icon" href="/favicon.ico">
|
||||
\\<link rel="manifest" href="/manifest.json">
|
||||
\\<link rel="stylesheet" href="/style.css">
|
||||
);
|
||||
try testing.expectEqual(3, data.links.len);
|
||||
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
|
||||
// stylesheet should be filtered out
|
||||
try testing.expectEqual(null, findProperty(data.links, "stylesheet"));
|
||||
}
|
||||
|
||||
test "structured_data: alternate links" {
|
||||
const data = try testStructuredData(
|
||||
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
|
||||
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
|
||||
);
|
||||
try testing.expectEqual(2, data.alternate.len);
|
||||
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
|
||||
try testing.expectEqual("French", data.alternate[0].title.?);
|
||||
try testing.expectEqual("de", data.alternate[1].hreflang.?);
|
||||
try testing.expectEqual(null, data.alternate[1].title);
|
||||
}
|
||||
|
||||
test "structured_data: non-metadata elements ignored" {
|
||||
const data = try testStructuredData(
|
||||
\\<div>Just text</div>
|
||||
\\<p>More text</p>
|
||||
\\<a href="/link">Link</a>
|
||||
);
|
||||
try testing.expectEqual(0, data.json_ld.len);
|
||||
try testing.expectEqual(0, data.open_graph.len);
|
||||
try testing.expectEqual(0, data.twitter_card.len);
|
||||
try testing.expectEqual(0, data.meta.len);
|
||||
try testing.expectEqual(0, data.links.len);
|
||||
}
|
||||
|
||||
test "structured_data: charset and http-equiv" {
|
||||
const data = try testStructuredData(
|
||||
\\<meta charset="utf-8">
|
||||
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
);
|
||||
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
|
||||
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
|
||||
}
|
||||
|
||||
test "structured_data: mixed content" {
|
||||
const data = try testStructuredData(
|
||||
\\<title>My Site</title>
|
||||
\\<meta property="og:title" content="OG Title">
|
||||
\\<meta name="twitter:card" content="summary">
|
||||
\\<meta name="description" content="A page">
|
||||
\\<link rel="canonical" href="https://example.com">
|
||||
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
|
||||
);
|
||||
try testing.expectEqual(1, data.json_ld.len);
|
||||
try testing.expectEqual(1, data.open_graph.len);
|
||||
try testing.expectEqual(1, data.twitter_card.len);
|
||||
try testing.expectEqual("My Site", findProperty(data.meta, "title").?);
|
||||
try testing.expectEqual("A page", findProperty(data.meta, "description").?);
|
||||
try testing.expectEqual(1, data.links.len);
|
||||
}
|
||||
@@ -98,6 +98,64 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=mime_parsing>
|
||||
// MIME types are lowercased
|
||||
{
|
||||
const blob = new Blob([], { type: "TEXT/HTML" });
|
||||
testing.expectEqual("text/html", blob.type);
|
||||
}
|
||||
|
||||
{
|
||||
const blob = new Blob([], { type: "Application/JSON" });
|
||||
testing.expectEqual("application/json", blob.type);
|
||||
}
|
||||
|
||||
// MIME with parameters - lowercased
|
||||
{
|
||||
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
|
||||
testing.expectEqual("text/html; charset=utf-8", blob.type);
|
||||
}
|
||||
|
||||
// Any ASCII string is accepted and lowercased (no MIME structure validation)
|
||||
{
|
||||
const blob = new Blob([], { type: "invalid" });
|
||||
testing.expectEqual("invalid", blob.type);
|
||||
}
|
||||
|
||||
{
|
||||
const blob = new Blob([], { type: "/" });
|
||||
testing.expectEqual("/", blob.type);
|
||||
}
|
||||
|
||||
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
|
||||
{
|
||||
const blob = new Blob([], { type: "ý/x" });
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
{
|
||||
const blob = new Blob([], { type: "text/plàin" });
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
// Control characters cause empty string
|
||||
{
|
||||
const blob = new Blob([], { type: "text/html\x00" });
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
// Empty type stays empty
|
||||
{
|
||||
const blob = new Blob([]);
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
|
||||
{
|
||||
const blob = new Blob([], { type: "" });
|
||||
testing.expectEqual("", blob.type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=slice>
|
||||
{
|
||||
const parts = ["la", "symphonie", "des", "éclairs"];
|
||||
|
||||
63
src/browser/tests/css/font_face.html
Normal file
63
src/browser/tests/css/font_face.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id="constructor_basic">
|
||||
{
|
||||
const face = new FontFace("TestFont", "url(test.woff)");
|
||||
testing.expectTrue(face instanceof FontFace);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="constructor_name">
|
||||
{
|
||||
testing.expectEqual('FontFace', FontFace.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="family_property">
|
||||
{
|
||||
const face = new FontFace("MyFont", "url(font.woff2)");
|
||||
testing.expectEqual("MyFont", face.family);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="status_is_loaded">
|
||||
{
|
||||
const face = new FontFace("F", "url(f.woff)");
|
||||
testing.expectEqual("loaded", face.status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="loaded_is_promise">
|
||||
{
|
||||
const face = new FontFace("F", "url(f.woff)");
|
||||
testing.expectTrue(face.loaded instanceof Promise);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="load_returns_promise">
|
||||
{
|
||||
const face = new FontFace("F", "url(f.woff)");
|
||||
testing.expectTrue(face.load() instanceof Promise);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="default_descriptors">
|
||||
{
|
||||
const face = new FontFace("F", "url(f.woff)");
|
||||
testing.expectEqual("normal", face.style);
|
||||
testing.expectEqual("normal", face.weight);
|
||||
testing.expectEqual("normal", face.stretch);
|
||||
testing.expectEqual("normal", face.variant);
|
||||
testing.expectEqual("normal", face.featureSettings);
|
||||
testing.expectEqual("auto", face.display);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="document_fonts_add">
|
||||
{
|
||||
const face = new FontFace("AddedFont", "url(added.woff)");
|
||||
const result = document.fonts.add(face);
|
||||
testing.expectTrue(result === document.fonts);
|
||||
}
|
||||
</script>
|
||||
@@ -256,3 +256,166 @@
|
||||
testing.expectTrue(!html.includes('opacity:0'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="CSSStyleDeclaration_non_ascii_custom_property">
|
||||
{
|
||||
// Regression test: accessing element.style must not crash when the inline
|
||||
// style attribute contains CSS custom properties with non-ASCII (UTF-8
|
||||
// multibyte) names, such as French accented characters.
|
||||
// The CSS Tokenizer's consumeName() must advance over whole UTF-8 sequences
|
||||
// rather than byte-by-byte to avoid landing on a continuation byte.
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('style',
|
||||
'--color-store-bulles-\u00e9t\u00e9-fg: #6a818f;' +
|
||||
'--color-store-soir\u00e9es-odl-fg: #56b3b3;' +
|
||||
'color: red;'
|
||||
);
|
||||
|
||||
// Must not crash, and ASCII properties that follow non-ASCII ones must be readable.
|
||||
testing.expectEqual('red', div.style.getPropertyValue('color'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
|
||||
{
|
||||
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.style.width = '0';
|
||||
testing.expectEqual('0px', div.style.width);
|
||||
|
||||
div.style.margin = '0';
|
||||
testing.expectEqual('0px', div.style.margin);
|
||||
|
||||
div.style.padding = '0';
|
||||
testing.expectEqual('0px', div.style.padding);
|
||||
|
||||
div.style.top = '0';
|
||||
testing.expectEqual('0px', div.style.top);
|
||||
|
||||
// Scroll properties
|
||||
div.style.scrollMarginTop = '0';
|
||||
testing.expectEqual('0px', div.style.scrollMarginTop);
|
||||
|
||||
div.style.scrollPaddingBottom = '0';
|
||||
testing.expectEqual('0px', div.style.scrollPaddingBottom);
|
||||
|
||||
// Multi-column
|
||||
div.style.columnWidth = '0';
|
||||
testing.expectEqual('0px', div.style.columnWidth);
|
||||
|
||||
div.style.columnRuleWidth = '0';
|
||||
testing.expectEqual('0px', div.style.columnRuleWidth);
|
||||
|
||||
// Outline shorthand
|
||||
div.style.outline = '0';
|
||||
testing.expectEqual('0px', div.style.outline);
|
||||
|
||||
// Shapes
|
||||
div.style.shapeMargin = '0';
|
||||
testing.expectEqual('0px', div.style.shapeMargin);
|
||||
|
||||
// Non-length properties should not be affected
|
||||
div.style.opacity = '0';
|
||||
testing.expectEqual('0', div.style.opacity);
|
||||
|
||||
div.style.zIndex = '0';
|
||||
testing.expectEqual('0', div.style.zIndex);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="CSSStyleDeclaration_normalize_first_baseline">
|
||||
{
|
||||
// "first baseline" should serialize canonically as "baseline"
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.style.alignItems = 'first baseline';
|
||||
testing.expectEqual('baseline', div.style.alignItems);
|
||||
|
||||
div.style.alignContent = 'first baseline';
|
||||
testing.expectEqual('baseline', div.style.alignContent);
|
||||
|
||||
div.style.alignSelf = 'first baseline';
|
||||
testing.expectEqual('baseline', div.style.alignSelf);
|
||||
|
||||
div.style.justifySelf = 'first baseline';
|
||||
testing.expectEqual('baseline', div.style.justifySelf);
|
||||
|
||||
// "last baseline" should remain unchanged
|
||||
div.style.alignItems = 'last baseline';
|
||||
testing.expectEqual('last baseline', div.style.alignItems);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="CSSStyleDeclaration_normalize_duplicate_values">
|
||||
{
|
||||
// For 2-value shorthand properties, "X X" should collapse to "X"
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.style.placeContent = 'center center';
|
||||
testing.expectEqual('center', div.style.placeContent);
|
||||
|
||||
div.style.placeContent = 'start start';
|
||||
testing.expectEqual('start', div.style.placeContent);
|
||||
|
||||
div.style.gap = '10px 10px';
|
||||
testing.expectEqual('10px', div.style.gap);
|
||||
|
||||
// Different values should not collapse
|
||||
div.style.placeContent = 'center start';
|
||||
testing.expectEqual('center start', div.style.placeContent);
|
||||
|
||||
div.style.gap = '10px 20px';
|
||||
testing.expectEqual('10px 20px', div.style.gap);
|
||||
|
||||
// New shorthands
|
||||
div.style.overflow = 'hidden hidden';
|
||||
testing.expectEqual('hidden', div.style.overflow);
|
||||
|
||||
div.style.scrollSnapAlign = 'start start';
|
||||
testing.expectEqual('start', div.style.scrollSnapAlign);
|
||||
|
||||
div.style.overscrollBehavior = 'auto auto';
|
||||
testing.expectEqual('auto', div.style.overscrollBehavior);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="CSSStyleDeclaration_normalize_anchor_size">
|
||||
{
|
||||
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
|
||||
const div = document.createElement('div');
|
||||
|
||||
// Already canonical order - should stay the same
|
||||
div.style.width = 'anchor-size(--foo width)';
|
||||
testing.expectEqual('anchor-size(--foo width)', div.style.width);
|
||||
|
||||
// Non-canonical order - should be reordered
|
||||
div.style.width = 'anchor-size(width --foo)';
|
||||
testing.expectEqual('anchor-size(--foo width)', div.style.width);
|
||||
|
||||
// With fallback value
|
||||
div.style.width = 'anchor-size(height --bar, 100px)';
|
||||
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
|
||||
|
||||
// Different size keywords
|
||||
div.style.width = 'anchor-size(block --baz)';
|
||||
testing.expectEqual('anchor-size(--baz block)', div.style.width);
|
||||
|
||||
div.style.width = 'anchor-size(inline --qux)';
|
||||
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
|
||||
|
||||
div.style.width = 'anchor-size(self-block --test)';
|
||||
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
|
||||
|
||||
div.style.width = 'anchor-size(self-inline --test)';
|
||||
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
|
||||
|
||||
// Without anchor name (implicit default anchor)
|
||||
div.style.width = 'anchor-size(width)';
|
||||
testing.expectEqual('anchor-size(width)', div.style.width);
|
||||
|
||||
// Nested anchor-size in fallback
|
||||
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
|
||||
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,3 +53,78 @@
|
||||
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=clone_container></div>
|
||||
|
||||
<script id=clone>
|
||||
{
|
||||
let calls = 0;
|
||||
class MyCloneElementA extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
$('#clone_container').appendChild(this);
|
||||
}
|
||||
}
|
||||
customElements.define('my-clone_element_a', MyCloneElementA);
|
||||
const original = document.createElement('my-clone_element_a');
|
||||
$('#clone_container').cloneNode(true);
|
||||
testing.expectEqual(2, calls);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=fragment_clone_container></div>
|
||||
|
||||
<script id=clone_fragment>
|
||||
{
|
||||
let calls = 0;
|
||||
class MyFragmentCloneElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
$('#fragment_clone_container').appendChild(this);
|
||||
}
|
||||
}
|
||||
customElements.define('my-fragment-clone-element', MyFragmentCloneElement);
|
||||
|
||||
// Create a DocumentFragment with a custom element
|
||||
const fragment = document.createDocumentFragment();
|
||||
const customEl = document.createElement('my-fragment-clone-element');
|
||||
fragment.appendChild(customEl);
|
||||
|
||||
// Clone the fragment - this should trigger the crash
|
||||
// because the constructor will attach the element during cloning
|
||||
const clonedFragment = fragment.cloneNode(true);
|
||||
testing.expectEqual(2, calls);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=range_clone_container></div>
|
||||
|
||||
<script id=clone_range>
|
||||
{
|
||||
let calls = 0;
|
||||
class MyRangeCloneElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
$('#range_clone_container').appendChild(this);
|
||||
}
|
||||
}
|
||||
customElements.define('my-range-clone-element', MyRangeCloneElement);
|
||||
|
||||
// Create a container with a custom element
|
||||
const container = document.createElement('div');
|
||||
const customEl = document.createElement('my-range-clone-element');
|
||||
container.appendChild(customEl);
|
||||
|
||||
// Create a range that includes the custom element
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(container);
|
||||
|
||||
// Clone the range contents - this should trigger the crash
|
||||
// because the constructor will attach the element during cloning
|
||||
const clonedContents = range.cloneContents();
|
||||
testing.expectEqual(2, calls);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -111,3 +111,15 @@
|
||||
const containerDataTest = document.querySelector('#container [data-test]');
|
||||
testing.expectEqual('First', containerDataTest.innerText);
|
||||
</script>
|
||||
|
||||
<link rel="preload" as="image" imagesrcset="url1.png 1x, url2.png 2x" id="preload-link">
|
||||
|
||||
<script id="commaInAttrValue">
|
||||
// Commas inside quoted attribute values must not be treated as selector separators
|
||||
const el = document.querySelector('link[rel="preload"][as="image"][imagesrcset="url1.png 1x, url2.png 2x"]');
|
||||
testing.expectEqual('preload-link', el.id);
|
||||
|
||||
// Also test with single quotes inside selector
|
||||
const el2 = document.querySelector("link[imagesrcset='url1.png 1x, url2.png 2x']");
|
||||
testing.expectEqual('preload-link', el2.id);
|
||||
</script>
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
|
||||
<script id=basic>
|
||||
{
|
||||
const parser = new DOMParser();
|
||||
testing.expectEqual('object', typeof parser);
|
||||
testing.expectEqual('function', typeof parser.parseFromString);
|
||||
{
|
||||
const parser = new DOMParser();
|
||||
testing.expectEqual('object', typeof parser);
|
||||
testing.expectEqual('function', typeof parser.parseFromString);
|
||||
}
|
||||
|
||||
{
|
||||
// Empty XML is a parse error (no root element)
|
||||
const parser = new DOMParser();
|
||||
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -389,3 +397,25 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=getElementsByTagName-xml>
|
||||
{
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
|
||||
|
||||
// Test getElementsByTagName on document
|
||||
const rows = doc.getElementsByTagName('row');
|
||||
testing.expectEqual(1, rows.length);
|
||||
|
||||
// Test getElementsByTagName on element
|
||||
const row = rows[0];
|
||||
const cols = row.getElementsByTagName('col');
|
||||
testing.expectEqual(2, cols.length);
|
||||
testing.expectEqual('A', cols[0].textContent);
|
||||
testing.expectEqual('B', cols[1].textContent);
|
||||
|
||||
// Test getElementsByTagName('*') on element
|
||||
const allElements = row.getElementsByTagName('*');
|
||||
testing.expectEqual(2, allElements.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
63
src/browser/tests/element/html/details.html
Normal file
63
src/browser/tests/element/html/details.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<!-- Details elements -->
|
||||
<details id="details1">
|
||||
<summary>Summary</summary>
|
||||
Content
|
||||
</details>
|
||||
<details id="details2" open>
|
||||
<summary>Open Summary</summary>
|
||||
Content
|
||||
</details>
|
||||
|
||||
<script id="instanceof">
|
||||
{
|
||||
const details = document.createElement('details')
|
||||
testing.expectTrue(details instanceof HTMLDetailsElement)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="open_initial">
|
||||
testing.expectEqual(false, $('#details1').open)
|
||||
testing.expectEqual(true, $('#details2').open)
|
||||
</script>
|
||||
|
||||
<script id="open_set">
|
||||
{
|
||||
$('#details1').open = true
|
||||
testing.expectEqual(true, $('#details1').open)
|
||||
|
||||
$('#details2').open = false
|
||||
testing.expectEqual(false, $('#details2').open)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="open_reflects_attribute">
|
||||
{
|
||||
const details = document.createElement('details')
|
||||
testing.expectEqual(null, details.getAttribute('open'))
|
||||
|
||||
details.open = true
|
||||
testing.expectEqual('', details.getAttribute('open'))
|
||||
|
||||
details.open = false
|
||||
testing.expectEqual(null, details.getAttribute('open'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="name_initial">
|
||||
{
|
||||
const details = document.createElement('details')
|
||||
testing.expectEqual('', details.name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="name_set">
|
||||
{
|
||||
const details = document.createElement('details')
|
||||
details.name = 'group1'
|
||||
testing.expectEqual('group1', details.name)
|
||||
testing.expectEqual('group1', details.getAttribute('name'))
|
||||
}
|
||||
</script>
|
||||
@@ -23,6 +23,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="action">
|
||||
{
|
||||
const form = document.createElement('form')
|
||||
testing.expectEqual(testing.BASE_URL + 'element/html/form.html', form.action)
|
||||
|
||||
form.action = 'hello';
|
||||
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
|
||||
|
||||
form.action = '/hello';
|
||||
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
|
||||
|
||||
form.action = 'https://lightpanda.io/hello';
|
||||
testing.expectEqual('https://lightpanda.io/hello', form.action)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test fixtures for form.method -->
|
||||
<form id="form_get" method="get"></form>
|
||||
<form id="form_post" method="post"></form>
|
||||
|
||||
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal file
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<head></head>
|
||||
<script src="../../../testing.js"></script>
|
||||
|
||||
<script id=textContent_inline>
|
||||
window.inline_executed = false;
|
||||
const s1 = document.createElement('script');
|
||||
s1.textContent = 'window.inline_executed = true;';
|
||||
document.head.appendChild(s1);
|
||||
testing.expectTrue(window.inline_executed);
|
||||
</script>
|
||||
|
||||
<script id=text_property_inline>
|
||||
window.text_executed = false;
|
||||
const s2 = document.createElement('script');
|
||||
s2.text = 'window.text_executed = true;';
|
||||
document.head.appendChild(s2);
|
||||
testing.expectTrue(window.text_executed);
|
||||
</script>
|
||||
|
||||
<script id=innerHTML_inline>
|
||||
window.innerHTML_executed = false;
|
||||
const s3 = document.createElement('script');
|
||||
s3.innerHTML = 'window.innerHTML_executed = true;';
|
||||
document.head.appendChild(s3);
|
||||
testing.expectTrue(window.innerHTML_executed);
|
||||
</script>
|
||||
|
||||
<script id=no_double_execute_inline>
|
||||
window.inline_counter = 0;
|
||||
const s4 = document.createElement('script');
|
||||
s4.textContent = 'window.inline_counter++;';
|
||||
document.head.appendChild(s4);
|
||||
document.head.appendChild(s4);
|
||||
testing.expectEqual(1, window.inline_counter);
|
||||
</script>
|
||||
|
||||
<script id=empty_script_no_execute>
|
||||
window.empty_ran = false;
|
||||
const s5 = document.createElement('script');
|
||||
document.head.appendChild(s5);
|
||||
testing.expectFalse(window.empty_ran);
|
||||
</script>
|
||||
|
||||
<script id=module_inline>
|
||||
window.module_executed = false;
|
||||
const s6 = document.createElement('script');
|
||||
s6.type = 'module';
|
||||
s6.textContent = 'window.module_executed = true;';
|
||||
document.head.appendChild(s6);
|
||||
testing.eventually(() => {
|
||||
testing.expectTrue(window.module_executed);
|
||||
});
|
||||
</script>
|
||||
75
src/browser/tests/element/html/track.html
Normal file
75
src/browser/tests/element/html/track.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<video id="video1">
|
||||
<track id="track1" kind="subtitles">
|
||||
<track id="track2" kind="captions">
|
||||
<track id="track3" kind="invalid-kind">
|
||||
</video>
|
||||
|
||||
<script id="instanceof">
|
||||
{
|
||||
const track = document.createElement("track");
|
||||
testing.expectEqual(true, track instanceof HTMLTrackElement);
|
||||
testing.expectEqual("[object HTMLTrackElement]", track.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="kind_default">
|
||||
{
|
||||
const track = document.createElement("track");
|
||||
testing.expectEqual("subtitles", track.kind);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="kind_valid_values">
|
||||
{
|
||||
const track = document.createElement("track");
|
||||
|
||||
track.kind = "captions";
|
||||
testing.expectEqual("captions", track.kind);
|
||||
|
||||
track.kind = "descriptions";
|
||||
testing.expectEqual("descriptions", track.kind);
|
||||
|
||||
track.kind = "chapters";
|
||||
testing.expectEqual("chapters", track.kind);
|
||||
|
||||
track.kind = "metadata";
|
||||
testing.expectEqual("metadata", track.kind);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="kind_invalid">
|
||||
{
|
||||
const track = document.createElement("track");
|
||||
|
||||
track.kind = null;
|
||||
testing.expectEqual("metadata", track.kind);
|
||||
|
||||
track.kind = "Subtitles";
|
||||
testing.expectEqual("subtitles", track.kind);
|
||||
|
||||
track.kind = "";
|
||||
testing.expectEqual("metadata", track.kind);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="constants">
|
||||
{
|
||||
const track = document.createElement("track");
|
||||
testing.expectEqual(0, track.NONE);
|
||||
testing.expectEqual(1, track.LOADING);
|
||||
testing.expectEqual(2, track.LOADED);
|
||||
testing.expectEqual(3, track.ERROR);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="constants_static">
|
||||
{
|
||||
testing.expectEqual(0, HTMLTrackElement.NONE);
|
||||
testing.expectEqual(1, HTMLTrackElement.LOADING);
|
||||
testing.expectEqual(2, HTMLTrackElement.LOADED);
|
||||
testing.expectEqual(3, HTMLTrackElement.ERROR);
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +81,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="is_empty">
|
||||
{
|
||||
// Empty :is() and :where() are valid per spec and match nothing
|
||||
const isEmptyResult = document.querySelectorAll(':is()');
|
||||
testing.expectEqual(0, isEmptyResult.length);
|
||||
|
||||
const whereEmptyResult = document.querySelectorAll(':where()');
|
||||
testing.expectEqual(0, whereEmptyResult.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=escaped class=":popover-open"></div>
|
||||
<script id="escaped">
|
||||
{
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
// Empty functional pseudo-classes should error
|
||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
|
||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
|
||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':is()'));
|
||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':where()'));
|
||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,52 +3,73 @@
|
||||
|
||||
<script>
|
||||
function frame1Onload() {
|
||||
window.f1_onload = true;
|
||||
window.f1_onload = 'f1_onload_loaded';
|
||||
}
|
||||
</script>
|
||||
|
||||
<iframe id=f1 onload="frame1Onload" src="support/sub 1.html"></iframe>
|
||||
<iframe id=f0></iframe>
|
||||
<iframe id=f1 onload="frame1Onload()" src="support/sub 1.html"></iframe>
|
||||
<iframe id=f2 src="support/sub2.html"></iframe>
|
||||
|
||||
<script id="basic">
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(undefined, window[10]);
|
||||
<script id=empty>
|
||||
{
|
||||
const blank = document.createElement('iframe');
|
||||
testing.expectEqual(null, blank.contentDocument);
|
||||
document.documentElement.appendChild(blank);
|
||||
testing.expectEqual('<html><head></head><body></body></html>', blank.contentDocument.documentElement.outerHTML);
|
||||
|
||||
testing.expectEqual(window, window[0].top);
|
||||
testing.expectEqual(window, window[0].parent);
|
||||
testing.expectEqual(false, window === window[0]);
|
||||
const f0 = $('#f0')
|
||||
testing.expectEqual('<html><head></head><body></body></html>', f0.contentDocument.documentElement.outerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="basic">
|
||||
// reload it
|
||||
$('#f2').src = 'support/sub2.html';
|
||||
testing.expectEqual(true, true);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(undefined, window[20]);
|
||||
|
||||
testing.expectEqual(window, window[1].top);
|
||||
testing.expectEqual(window, window[1].parent);
|
||||
testing.expectEqual(false, window === window[1]);
|
||||
testing.expectEqual(false, window[0] === window[1]);
|
||||
|
||||
testing.expectEqual(window, window[2].top);
|
||||
testing.expectEqual(window, window[2].parent);
|
||||
testing.expectEqual(false, window === window[2]);
|
||||
testing.expectEqual(false, window[1] === window[2]);
|
||||
|
||||
testing.expectEqual(0, $('#f1').childNodes.length);
|
||||
|
||||
testing.expectEqual(testing.BASE_URL + 'frames/support/sub%201.html', $('#f1').src);
|
||||
testing.expectEqual(window[0], $('#f1').contentWindow);
|
||||
testing.expectEqual(window[1], $('#f2').contentWindow);
|
||||
testing.expectEqual(window[1], $('#f1').contentWindow);
|
||||
testing.expectEqual(window[2], $('#f2').contentWindow);
|
||||
|
||||
testing.expectEqual(window[0].document, $('#f1').contentDocument);
|
||||
testing.expectEqual(window[1].document, $('#f2').contentDocument);
|
||||
testing.expectEqual(window[1].document, $('#f1').contentDocument);
|
||||
testing.expectEqual(window[2].document, $('#f2').contentDocument);
|
||||
|
||||
// sibling frames share the same top
|
||||
testing.expectEqual(window[0].top, window[1].top);
|
||||
testing.expectEqual(window[1].top, window[2].top);
|
||||
|
||||
// child frames have no sub-frames
|
||||
testing.expectEqual(0, window[0].length);
|
||||
testing.expectEqual(0, window[1].length);
|
||||
testing.expectEqual(0, window[2].length);
|
||||
|
||||
// self and window are self-referential on child frames
|
||||
testing.expectEqual(window[0], window[0].self);
|
||||
testing.expectEqual(window[0], window[0].window);
|
||||
testing.expectEqual(window[1], window[1].self);
|
||||
testing.expectEqual(window[1], window[1].window);
|
||||
testing.expectEqual(window[2], window[2].self);
|
||||
|
||||
// child frame's top.parent is itself (root has no parent)
|
||||
testing.expectEqual(window, window[0].top.parent);
|
||||
|
||||
// testing.expectEqual(true, window.sub1_loaded);
|
||||
// testing.expectEqual(true, window.sub2_loaded);
|
||||
// Cross-frame property access
|
||||
testing.expectEqual(true, window.sub1_loaded);
|
||||
testing.expectEqual(true, window.sub2_loaded);
|
||||
testing.expectEqual(1, window.sub1_count);
|
||||
// depends on how far the initial load got before it was cancelled.
|
||||
testing.expectEqual(true, window.sub2_count == 1 || window.sub2_count == 2);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -56,6 +77,7 @@
|
||||
{
|
||||
let f3_load_event = false;
|
||||
let f3 = document.createElement('iframe');
|
||||
f3.id = 'f3';
|
||||
f3.addEventListener('load', () => {
|
||||
f3_load_event = true;
|
||||
});
|
||||
@@ -63,14 +85,62 @@
|
||||
document.documentElement.appendChild(f3);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(true, window.f1_onload);
|
||||
testing.expectEqual('f1_onload_loaded', window.f1_onload);
|
||||
testing.expectEqual(true, f3_load_event);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=count>
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(3, window.length);
|
||||
<script id=about_blank>
|
||||
{
|
||||
let f4 = document.createElement('iframe');
|
||||
f4.id = 'f4';
|
||||
f4.src = "about:blank";
|
||||
document.documentElement.appendChild(f4);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual("<html><head></head><body></body></html>", f4.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=about_blank_renavigate>
|
||||
{
|
||||
let f5 = document.createElement('iframe');
|
||||
f5.id = 'f5';
|
||||
f5.src = "support/sub 1.html";
|
||||
document.documentElement.appendChild(f5);
|
||||
f5.src = "about:blank";
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=link_click>
|
||||
testing.async(async (restore) => {
|
||||
await new Promise((resolve) => {
|
||||
let count = 0;
|
||||
let f6 = document.createElement('iframe');
|
||||
f6.id = 'f6';
|
||||
f6.addEventListener('load', () => {
|
||||
if (++count == 2) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
f6.contentDocument.querySelector('#link').click();
|
||||
});
|
||||
f6.src = "support/with_link.html";
|
||||
document.documentElement.appendChild(f6);
|
||||
});
|
||||
restore();
|
||||
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=count>
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(8, window.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
2
src/browser/tests/frames/support/after_link.html
Normal file
2
src/browser/tests/frames/support/after_link.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
It was clicked!
|
||||
@@ -3,4 +3,5 @@
|
||||
<script>
|
||||
// should not have access to the parent's JS context
|
||||
window.top.sub1_loaded = window.testing == undefined;
|
||||
window.top.sub1_count = (window.top.sub1_count || 0) + 1;
|
||||
</script>
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
<script>
|
||||
// should not have access to the parent's JS context
|
||||
window.top.sub2_loaded = window.testing == undefined;
|
||||
window.top.sub2_count = (window.top.sub2_count || 0) + 1;
|
||||
</script>
|
||||
|
||||
2
src/browser/tests/frames/support/with_link.html
Normal file
2
src/browser/tests/frames/support/with_link.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<a href="support/after_link.html" id=link>a link</a>
|
||||
@@ -6,6 +6,7 @@
|
||||
</html>
|
||||
|
||||
<script src="../testing.js"></script>
|
||||
<applet></applet>
|
||||
|
||||
<script id=document>
|
||||
testing.expectEqual('HTMLDocument', document.__proto__.constructor.name);
|
||||
@@ -23,7 +24,7 @@
|
||||
testing.expectEqual(2, document.scripts.length);
|
||||
testing.expectEqual(0, document.forms.length);
|
||||
testing.expectEqual(1, document.links.length);
|
||||
testing.expectEqual(0, document.applets.length);
|
||||
testing.expectEqual(0, document.applets.length); // deprecated, always returns 0
|
||||
testing.expectEqual(0, document.anchors.length);
|
||||
testing.expectEqual(7, document.all.length);
|
||||
testing.expectEqual('document', document.currentScript.id);
|
||||
|
||||
@@ -137,3 +137,79 @@
|
||||
testing.expectEqual('PROPFIND', req.method);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=body_methods>
|
||||
testing.async(async () => {
|
||||
const req = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: 'Hello, World!',
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
|
||||
const text = await req.text();
|
||||
testing.expectEqual('Hello, World!', text);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const req = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: '{"name": "test"}',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const json = await req.json();
|
||||
testing.expectEqual('test', json.name);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const req = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: 'binary data',
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
});
|
||||
|
||||
const buffer = await req.arrayBuffer();
|
||||
testing.expectEqual(true, buffer instanceof ArrayBuffer);
|
||||
testing.expectEqual(11, buffer.byteLength);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const req = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: 'blob content',
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
|
||||
const blob = await req.blob();
|
||||
testing.expectEqual(true, blob instanceof Blob);
|
||||
testing.expectEqual(12, blob.size);
|
||||
testing.expectEqual('text/plain', blob.type);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const req = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: 'bytes'
|
||||
});
|
||||
|
||||
const bytes = await req.bytes();
|
||||
testing.expectEqual(true, bytes instanceof Uint8Array);
|
||||
testing.expectEqual(5, bytes.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=clone>
|
||||
{
|
||||
const req1 = new Request('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: 'test body',
|
||||
headers: { 'X-Custom': 'value' }
|
||||
});
|
||||
|
||||
const req2 = req1.clone();
|
||||
|
||||
testing.expectEqual(req1.url, req2.url);
|
||||
testing.expectEqual(req1.method, req2.method);
|
||||
testing.expectEqual('value', req2.headers.get('X-Custom'));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,51 +2,113 @@
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=response>
|
||||
// let response = new Response("Hello, World!");
|
||||
// testing.expectEqual(200, response.status);
|
||||
// testing.expectEqual("", response.statusText);
|
||||
// testing.expectEqual(true, response.ok);
|
||||
// testing.expectEqual("", response.url);
|
||||
// testing.expectEqual(false, response.redirected);
|
||||
{
|
||||
let response = new Response("Hello, World!");
|
||||
testing.expectEqual(200, response.status);
|
||||
testing.expectEqual("", response.statusText);
|
||||
testing.expectEqual(true, response.ok);
|
||||
testing.expectEqual("", response.url);
|
||||
testing.expectEqual(false, response.redirected);
|
||||
}
|
||||
|
||||
let response2 = new Response("Error occurred", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"X-Custom": "test-value",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
// testing.expectEqual(404, response2.status);
|
||||
// testing.expectEqual("Not Found", response2.statusText);
|
||||
// testing.expectEqual(false, response2.ok);
|
||||
// testing.expectEqual("text/plain", response2.headers);
|
||||
// testing.expectEqual("test-value", response2.headers.get("X-Custom"));
|
||||
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
|
||||
|
||||
// let response3 = new Response("Created", { status: 201, statusText: "Created" });
|
||||
// testing.expectEqual("basic", response3.type);
|
||||
// testing.expectEqual(201, response3.status);
|
||||
// testing.expectEqual("Created", response3.statusText);
|
||||
// testing.expectEqual(true, response3.ok);
|
||||
|
||||
// let nullResponse = new Response(null);
|
||||
// testing.expectEqual(200, nullResponse.status);
|
||||
// testing.expectEqual("", nullResponse.statusText);
|
||||
|
||||
// let emptyResponse = new Response("");
|
||||
// testing.expectEqual(200, emptyResponse.status);
|
||||
</script>
|
||||
|
||||
<!-- <script id=json>
|
||||
testing.async(async () => {
|
||||
const json = await new Promise((resolve) => {
|
||||
let response = new Response('[]');
|
||||
response.json().then(resolve)
|
||||
{
|
||||
let response2 = new Response("Error occurred", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"X-Custom": "test-value",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
});
|
||||
testing.expectEqual([], json);
|
||||
testing.expectEqual(404, response2.status);
|
||||
testing.expectEqual("Not Found", response2.statusText);
|
||||
testing.expectEqual(false, response2.ok);
|
||||
testing.expectEqual("test-value", response2.headers.get("X-Custom"));
|
||||
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
|
||||
}
|
||||
|
||||
{
|
||||
let response3 = new Response("Created", { status: 201, statusText: "Created" });
|
||||
testing.expectEqual("basic", response3.type);
|
||||
testing.expectEqual(201, response3.status);
|
||||
testing.expectEqual("Created", response3.statusText);
|
||||
testing.expectEqual(true, response3.ok);
|
||||
}
|
||||
|
||||
{
|
||||
let nullResponse = new Response(null);
|
||||
testing.expectEqual(200, nullResponse.status);
|
||||
testing.expectEqual("", nullResponse.statusText);
|
||||
}
|
||||
|
||||
{
|
||||
let emptyResponse = new Response("");
|
||||
testing.expectEqual(200, emptyResponse.status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=body_methods>
|
||||
testing.async(async () => {
|
||||
const response = new Response('Hello, World!');
|
||||
const text = await response.text();
|
||||
testing.expectEqual('Hello, World!', text);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const response = new Response('{"name": "test"}');
|
||||
const json = await response.json();
|
||||
testing.expectEqual('test', json.name);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const response = new Response('binary data');
|
||||
const buffer = await response.arrayBuffer();
|
||||
testing.expectEqual(true, buffer instanceof ArrayBuffer);
|
||||
testing.expectEqual(11, buffer.byteLength);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const response = new Response('blob content', {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
});
|
||||
const blob = await response.blob();
|
||||
testing.expectEqual(true, blob instanceof Blob);
|
||||
testing.expectEqual(12, blob.size);
|
||||
testing.expectEqual('text/plain', blob.type);
|
||||
});
|
||||
|
||||
testing.async(async () => {
|
||||
const response = new Response('bytes');
|
||||
const bytes = await response.bytes();
|
||||
testing.expectEqual(true, bytes instanceof Uint8Array);
|
||||
testing.expectEqual(5, bytes.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=clone>
|
||||
{
|
||||
const response1 = new Response('test body', {
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
headers: { 'X-Custom': 'value' }
|
||||
});
|
||||
|
||||
const response2 = response1.clone();
|
||||
|
||||
testing.expectEqual(response1.status, response2.status);
|
||||
testing.expectEqual(response1.statusText, response2.statusText);
|
||||
testing.expectEqual('value', response2.headers.get('X-Custom'));
|
||||
}
|
||||
|
||||
testing.async(async () => {
|
||||
const response1 = new Response('cloned body');
|
||||
const response2 = response1.clone();
|
||||
|
||||
const text1 = await response1.text();
|
||||
const text2 = await response2.text();
|
||||
|
||||
testing.expectEqual('cloned body', text1);
|
||||
testing.expectEqual('cloned body', text2);
|
||||
});
|
||||
</script>
|
||||
-->
|
||||
|
||||
@@ -1022,3 +1022,50 @@
|
||||
testing.expectEqual('Stnd', div.textContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=getBoundingClientRect_collapsed>
|
||||
{
|
||||
const range = new Range();
|
||||
const rect = range.getBoundingClientRect();
|
||||
testing.expectTrue(rect instanceof DOMRect);
|
||||
testing.expectEqual(0, rect.x);
|
||||
testing.expectEqual(0, rect.y);
|
||||
testing.expectEqual(0, rect.width);
|
||||
testing.expectEqual(0, rect.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=getBoundingClientRect_element>
|
||||
{
|
||||
const range = new Range();
|
||||
const p = document.getElementById('p1');
|
||||
range.selectNodeContents(p);
|
||||
const rect = range.getBoundingClientRect();
|
||||
testing.expectTrue(rect instanceof DOMRect);
|
||||
// Non-collapsed range delegates to the container element
|
||||
const elemRect = p.getBoundingClientRect();
|
||||
testing.expectEqual(elemRect.x, rect.x);
|
||||
testing.expectEqual(elemRect.y, rect.y);
|
||||
testing.expectEqual(elemRect.width, rect.width);
|
||||
testing.expectEqual(elemRect.height, rect.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=getClientRects_collapsed>
|
||||
{
|
||||
const range = new Range();
|
||||
const rects = range.getClientRects();
|
||||
testing.expectEqual(0, rects.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=getClientRects_element>
|
||||
{
|
||||
const range = new Range();
|
||||
const p = document.getElementById('p1');
|
||||
range.selectNodeContents(p);
|
||||
const rects = range.getClientRects();
|
||||
const elemRects = p.getClientRects();
|
||||
testing.expectEqual(elemRects.length, rects.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
315
src/browser/tests/range_mutations.html
Normal file
315
src/browser/tests/range_mutations.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="testing.js"></script>
|
||||
|
||||
<script id=insertData_adjusts_range_offsets>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
// range covers "cde"
|
||||
|
||||
// Insert "XX" at offset 1 (before range start)
|
||||
text.insertData(1, 'XX');
|
||||
// "aXXbcdef" — range should shift right by 2
|
||||
testing.expectEqual(4, range.startOffset);
|
||||
testing.expectEqual(7, range.endOffset);
|
||||
testing.expectEqual(text, range.startContainer);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=insertData_at_range_start>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Insert at exactly the start offset — should not shift start
|
||||
text.insertData(2, 'YY');
|
||||
// "abYYcdef" — start stays at 2, end shifts by 2
|
||||
testing.expectEqual(2, range.startOffset);
|
||||
testing.expectEqual(7, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=insertData_inside_range>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Insert inside the range
|
||||
text.insertData(3, 'Z');
|
||||
// "abcZdef" — start unchanged, end shifts by 1
|
||||
testing.expectEqual(2, range.startOffset);
|
||||
testing.expectEqual(6, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=insertData_after_range>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Insert after range end — no change
|
||||
text.insertData(5, 'ZZ');
|
||||
testing.expectEqual(2, range.startOffset);
|
||||
testing.expectEqual(5, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=deleteData_before_range>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 3);
|
||||
range.setEnd(text, 5);
|
||||
// range covers "de"
|
||||
|
||||
// Delete "ab" (offset 0, count 2) — before range
|
||||
text.deleteData(0, 2);
|
||||
// "cdef" — range shifts left by 2
|
||||
testing.expectEqual(1, range.startOffset);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=deleteData_overlapping_range_start>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Delete from offset 1, count 2 — overlaps range start
|
||||
text.deleteData(1, 2);
|
||||
// "adef" — start clamped to offset(1), end adjusted
|
||||
testing.expectEqual(1, range.startOffset);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=deleteData_inside_range>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 1);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Delete inside range: offset 2, count 2
|
||||
text.deleteData(2, 2);
|
||||
// "abef" — start unchanged, end shifts by -2
|
||||
testing.expectEqual(1, range.startOffset);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replaceData_adjusts_range>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Replace "cd" (offset 2, count 2) with "XXXX" (4 chars)
|
||||
text.replaceData(2, 2, 'XXXX');
|
||||
// "abXXXXef" — start clamped to 2, end adjusted by (4-2)=+2
|
||||
testing.expectEqual(2, range.startOffset);
|
||||
testing.expectEqual(7, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=splitText_moves_range_to_new_node>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 4);
|
||||
range.setEnd(text, 6);
|
||||
// range covers "ef"
|
||||
|
||||
const newText = text.splitText(3);
|
||||
// text = "abc", newText = "def"
|
||||
// Range was at (text, 4)-(text, 6), with offset > 3:
|
||||
// start moves to (newText, 4-3=1), end moves to (newText, 6-3=3)
|
||||
testing.expectEqual(newText, range.startContainer);
|
||||
testing.expectEqual(1, range.startOffset);
|
||||
testing.expectEqual(newText, range.endContainer);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=splitText_range_at_split_point>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, 3);
|
||||
// range covers "abc"
|
||||
|
||||
const newText = text.splitText(3);
|
||||
// text = "abc", newText = "def"
|
||||
// Range end is at exactly the split offset — should stay on original node
|
||||
testing.expectEqual(text, range.startContainer);
|
||||
testing.expectEqual(0, range.startOffset);
|
||||
testing.expectEqual(text, range.endContainer);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=appendChild_does_not_affect_range>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
const p1 = document.createElement('p');
|
||||
const p2 = document.createElement('p');
|
||||
div.appendChild(p1);
|
||||
div.appendChild(p2);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(div, 0);
|
||||
range.setEnd(div, 2);
|
||||
|
||||
// Appending should not affect range offsets (spec: no update for append)
|
||||
const p3 = document.createElement('p');
|
||||
div.appendChild(p3);
|
||||
testing.expectEqual(0, range.startOffset);
|
||||
testing.expectEqual(2, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=insertBefore_shifts_range_offsets>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
const p1 = document.createElement('p');
|
||||
const p2 = document.createElement('p');
|
||||
div.appendChild(p1);
|
||||
div.appendChild(p2);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(div, 1);
|
||||
range.setEnd(div, 2);
|
||||
|
||||
// Insert before p1 (index 0) — range offsets > 0 should increment
|
||||
const span = document.createElement('span');
|
||||
div.insertBefore(span, p1);
|
||||
testing.expectEqual(2, range.startOffset);
|
||||
testing.expectEqual(3, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=removeChild_shifts_range_offsets>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
const p1 = document.createElement('p');
|
||||
const p2 = document.createElement('p');
|
||||
const p3 = document.createElement('p');
|
||||
div.appendChild(p1);
|
||||
div.appendChild(p2);
|
||||
div.appendChild(p3);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(div, 1);
|
||||
range.setEnd(div, 3);
|
||||
|
||||
// Remove p1 (index 0) — offsets > 0 should decrement
|
||||
div.removeChild(p1);
|
||||
testing.expectEqual(0, range.startOffset);
|
||||
testing.expectEqual(2, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=removeChild_moves_range_from_descendant>
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
const p = document.createElement('p');
|
||||
const text = document.createTextNode('hello');
|
||||
p.appendChild(text);
|
||||
div.appendChild(p);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 4);
|
||||
|
||||
// Remove p (which contains text) — range should move to (div, index_of_p)
|
||||
div.removeChild(p);
|
||||
testing.expectEqual(div, range.startContainer);
|
||||
testing.expectEqual(0, range.startOffset);
|
||||
testing.expectEqual(div, range.endContainer);
|
||||
testing.expectEqual(0, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=multiple_ranges_updated>
|
||||
{
|
||||
const text = document.createTextNode('abcdefgh');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range1 = document.createRange();
|
||||
range1.setStart(text, 1);
|
||||
range1.setEnd(text, 3);
|
||||
|
||||
const range2 = document.createRange();
|
||||
range2.setStart(text, 5);
|
||||
range2.setEnd(text, 7);
|
||||
|
||||
// Insert at offset 0 — both ranges should shift
|
||||
text.insertData(0, 'XX');
|
||||
testing.expectEqual(3, range1.startOffset);
|
||||
testing.expectEqual(5, range1.endOffset);
|
||||
testing.expectEqual(7, range2.startOffset);
|
||||
testing.expectEqual(9, range2.endOffset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=data_setter_updates_ranges>
|
||||
{
|
||||
const text = document.createTextNode('abcdef');
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(text);
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 2);
|
||||
range.setEnd(text, 5);
|
||||
|
||||
// Setting data replaces all content — range collapses to offset 0
|
||||
text.data = 'new content';
|
||||
testing.expectEqual(text, range.startContainer);
|
||||
testing.expectEqual(0, range.startOffset);
|
||||
testing.expectEqual(text, range.endContainer);
|
||||
testing.expectEqual(0, range.endOffset);
|
||||
}
|
||||
</script>
|
||||
@@ -301,3 +301,74 @@
|
||||
testing.expectEqual(false, data3.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=enqueue_preserves_number>
|
||||
(async function() {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(42);
|
||||
controller.enqueue(0);
|
||||
controller.enqueue(3.14);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
const r1 = await reader.read();
|
||||
testing.expectEqual(false, r1.done);
|
||||
testing.expectEqual('number', typeof r1.value);
|
||||
testing.expectEqual(42, r1.value);
|
||||
|
||||
const r2 = await reader.read();
|
||||
testing.expectEqual('number', typeof r2.value);
|
||||
testing.expectEqual(0, r2.value);
|
||||
|
||||
const r3 = await reader.read();
|
||||
testing.expectEqual('number', typeof r3.value);
|
||||
testing.expectEqual(3.14, r3.value);
|
||||
|
||||
const r4 = await reader.read();
|
||||
testing.expectEqual(true, r4.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=enqueue_preserves_bool>
|
||||
(async function() {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(true);
|
||||
controller.enqueue(false);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
const r1 = await reader.read();
|
||||
testing.expectEqual('boolean', typeof r1.value);
|
||||
testing.expectEqual(true, r1.value);
|
||||
|
||||
const r2 = await reader.read();
|
||||
testing.expectEqual('boolean', typeof r2.value);
|
||||
testing.expectEqual(false, r2.value);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=enqueue_preserves_object>
|
||||
(async function() {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ key: 'value', num: 7 });
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
const r1 = await reader.read();
|
||||
testing.expectEqual('object', typeof r1.value);
|
||||
testing.expectEqual('value', r1.value.key);
|
||||
testing.expectEqual(7, r1.value.num);
|
||||
})();
|
||||
</script>
|
||||
|
||||
82
src/browser/tests/streams/text_decoder_stream.html
Normal file
82
src/browser/tests/streams/text_decoder_stream.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=text_decoder_stream_encoding>
|
||||
{
|
||||
const tds = new TextDecoderStream();
|
||||
testing.expectEqual('utf-8', tds.encoding);
|
||||
testing.expectEqual('object', typeof tds.readable);
|
||||
testing.expectEqual('object', typeof tds.writable);
|
||||
testing.expectEqual(false, tds.fatal);
|
||||
testing.expectEqual(false, tds.ignoreBOM);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_decoder_stream_with_label>
|
||||
{
|
||||
const tds = new TextDecoderStream('utf-8');
|
||||
testing.expectEqual('utf-8', tds.encoding);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_decoder_stream_with_opts>
|
||||
{
|
||||
const tds = new TextDecoderStream('utf-8', { fatal: true, ignoreBOM: true });
|
||||
testing.expectEqual(true, tds.fatal);
|
||||
testing.expectEqual(true, tds.ignoreBOM);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_decoder_stream_invalid_label>
|
||||
{
|
||||
let errorThrown = false;
|
||||
try {
|
||||
new TextDecoderStream('windows-1252');
|
||||
} catch (e) {
|
||||
errorThrown = true;
|
||||
}
|
||||
testing.expectEqual(true, errorThrown);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_decoder_stream_decode>
|
||||
(async function() {
|
||||
const tds = new TextDecoderStream();
|
||||
|
||||
const writer = tds.writable.getWriter();
|
||||
const reader = tds.readable.getReader();
|
||||
|
||||
// 'hello' in UTF-8 bytes
|
||||
const bytes = new Uint8Array([104, 101, 108, 108, 111]);
|
||||
await writer.write(bytes);
|
||||
await writer.close();
|
||||
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual('hello', result.value);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=text_decoder_stream_empty_chunk>
|
||||
(async function() {
|
||||
const tds = new TextDecoderStream();
|
||||
const writer = tds.writable.getWriter();
|
||||
const reader = tds.readable.getReader();
|
||||
|
||||
// Write an empty chunk followed by real data
|
||||
await writer.write(new Uint8Array([]));
|
||||
await writer.write(new Uint8Array([104, 105]));
|
||||
await writer.close();
|
||||
|
||||
// Empty chunk should be filtered out; first read gets "hi"
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual('hi', result.value);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
164
src/browser/tests/streams/transform_stream.html
Normal file
164
src/browser/tests/streams/transform_stream.html
Normal file
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=transform_stream_basic>
|
||||
{
|
||||
const ts = new TransformStream();
|
||||
testing.expectEqual('object', typeof ts);
|
||||
testing.expectEqual('object', typeof ts.readable);
|
||||
testing.expectEqual('object', typeof ts.writable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=transform_stream_with_transformer>
|
||||
(async function() {
|
||||
const ts = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk.toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
const writer = ts.writable.getWriter();
|
||||
const reader = ts.readable.getReader();
|
||||
|
||||
await writer.write('hello');
|
||||
await writer.close();
|
||||
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual('HELLO', result.value);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=writable_stream_basic>
|
||||
{
|
||||
const ws = new WritableStream();
|
||||
testing.expectEqual('object', typeof ws);
|
||||
testing.expectEqual(false, ws.locked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=writable_stream_writer>
|
||||
{
|
||||
const ws = new WritableStream();
|
||||
const writer = ws.getWriter();
|
||||
testing.expectEqual('object', typeof writer);
|
||||
testing.expectEqual(true, ws.locked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=writable_stream_writer_desired_size>
|
||||
{
|
||||
const ws = new WritableStream();
|
||||
const writer = ws.getWriter();
|
||||
testing.expectEqual(1, writer.desiredSize);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_encoder_stream_encoding>
|
||||
{
|
||||
const tes = new TextEncoderStream();
|
||||
testing.expectEqual('utf-8', tes.encoding);
|
||||
testing.expectEqual('object', typeof tes.readable);
|
||||
testing.expectEqual('object', typeof tes.writable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=text_encoder_stream_encode>
|
||||
(async function() {
|
||||
const tes = new TextEncoderStream();
|
||||
|
||||
const writer = tes.writable.getWriter();
|
||||
const reader = tes.readable.getReader();
|
||||
|
||||
await writer.write('hi');
|
||||
await writer.close();
|
||||
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual(true, result.value instanceof Uint8Array);
|
||||
// 'hi' in UTF-8 is [104, 105]
|
||||
testing.expectEqual(104, result.value[0]);
|
||||
testing.expectEqual(105, result.value[1]);
|
||||
testing.expectEqual(2, result.value.length);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=pipe_through_basic>
|
||||
(async function() {
|
||||
const input = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue('hello');
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const ts = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk.toUpperCase());
|
||||
}
|
||||
});
|
||||
|
||||
const output = input.pipeThrough(ts);
|
||||
const reader = output.getReader();
|
||||
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual('HELLO', result.value);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=pipe_to_basic>
|
||||
(async function() {
|
||||
const chunks = [];
|
||||
const input = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue('a');
|
||||
controller.enqueue('b');
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const ws = new WritableStream({
|
||||
write(chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
await input.pipeTo(ws);
|
||||
testing.expectEqual(2, chunks.length);
|
||||
testing.expectEqual('a', chunks[0]);
|
||||
testing.expectEqual('b', chunks[1]);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=pipe_through_text_decoder>
|
||||
(async function() {
|
||||
const bytes = new Uint8Array([104, 101, 108, 108, 111]);
|
||||
const input = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const output = input.pipeThrough(new TextDecoderStream());
|
||||
const reader = output.getReader();
|
||||
|
||||
const result = await reader.read();
|
||||
testing.expectEqual(false, result.done);
|
||||
testing.expectEqual('hello', result.value);
|
||||
|
||||
const result2 = await reader.read();
|
||||
testing.expectEqual(true, result2.done);
|
||||
})();
|
||||
</script>
|
||||
@@ -118,7 +118,7 @@
|
||||
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
|
||||
};
|
||||
|
||||
if (!IS_TEST_RUNNER) {
|
||||
if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
|
||||
// The page is running in a different browser. Probably a developer making sure
|
||||
// a test is correct. There are a few tweaks we need to do to make this a
|
||||
// seemless, namely around adapting paths/urls.
|
||||
|
||||
@@ -218,6 +218,106 @@
|
||||
testing.expectEqual('', url.password);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.username = 'newuser';
|
||||
testing.expectEqual('newuser', url.username);
|
||||
testing.expectEqual('https://newuser@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://olduser@example.com/path');
|
||||
url.username = 'newuser';
|
||||
testing.expectEqual('newuser', url.username);
|
||||
testing.expectEqual('https://newuser@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://olduser:pass@example.com/path');
|
||||
url.username = 'newuser';
|
||||
testing.expectEqual('newuser', url.username);
|
||||
testing.expectEqual('pass', url.password);
|
||||
testing.expectEqual('https://newuser:pass@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://user@example.com/path');
|
||||
url.password = 'secret';
|
||||
testing.expectEqual('user', url.username);
|
||||
testing.expectEqual('secret', url.password);
|
||||
testing.expectEqual('https://user:secret@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://user:oldpass@example.com/path');
|
||||
url.password = 'newpass';
|
||||
testing.expectEqual('user', url.username);
|
||||
testing.expectEqual('newpass', url.password);
|
||||
testing.expectEqual('https://user:newpass@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://user:pass@example.com/path');
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
testing.expectEqual('', url.username);
|
||||
testing.expectEqual('', url.password);
|
||||
testing.expectEqual('https://example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.username = 'user@domain';
|
||||
testing.expectEqual('user%40domain', url.username);
|
||||
testing.expectEqual('https://user%40domain@example.com/path', url.href);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.username = 'user:name';
|
||||
testing.expectEqual('user%3Aname', url.username);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.password = 'pass@word';
|
||||
testing.expectEqual('pass%40word', url.password);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.password = 'pass:word';
|
||||
testing.expectEqual('pass%3Aword', url.password);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.username = 'user/name';
|
||||
testing.expectEqual('user%2Fname', url.username);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com/path');
|
||||
url.password = 'pass?word';
|
||||
testing.expectEqual('pass%3Fword', url.password);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://user%40domain:pass%3Aword@example.com/path');
|
||||
testing.expectEqual('user%40domain', url.username);
|
||||
testing.expectEqual('pass%3Aword', url.password);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('https://example.com:8080/path?a=b#hash');
|
||||
url.username = 'user';
|
||||
url.password = 'pass';
|
||||
testing.expectEqual('https://user:pass@example.com:8080/path?a=b#hash', url.href);
|
||||
testing.expectEqual('8080', url.port);
|
||||
testing.expectEqual('?a=b', url.search);
|
||||
testing.expectEqual('#hash', url.hash);
|
||||
}
|
||||
|
||||
{
|
||||
const url = new URL('http://user:pass@example.com:8080/path?query=1#hash');
|
||||
testing.expectEqual('http:', url.protocol);
|
||||
@@ -437,9 +537,9 @@
|
||||
{
|
||||
const url = new URL('https://example.com:8080/path');
|
||||
url.host = 'newhost.com';
|
||||
testing.expectEqual('https://newhost.com/path', url.href);
|
||||
testing.expectEqual('https://newhost.com:8080/path', url.href);
|
||||
testing.expectEqual('newhost.com', url.hostname);
|
||||
testing.expectEqual('', url.port);
|
||||
testing.expectEqual('8080', url.port);
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<body onload=func1></body>
|
||||
<body onload="func1(event)"></body>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=bodyOnLoad1>
|
||||
@@ -14,4 +14,3 @@
|
||||
testing.expectEqual(1, called);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
|
||||
|
||||
// length % 4 == 1 must still throw
|
||||
testing.expectError('Error: InvalidCharacterError', () => {
|
||||
testing.expectError('InvalidCharacterError: Invalid Character', () => {
|
||||
atob('Y');
|
||||
});
|
||||
</script>
|
||||
@@ -115,6 +115,30 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=structuredClone>
|
||||
// Basic types
|
||||
testing.expectEqual(42, structuredClone(42));
|
||||
testing.expectEqual('hello', structuredClone('hello'));
|
||||
testing.expectEqual(true, structuredClone(true));
|
||||
testing.expectEqual(null, structuredClone(null));
|
||||
|
||||
// Object deep clone
|
||||
const obj = { a: 1, b: { c: 2 } };
|
||||
const cloned = structuredClone(obj);
|
||||
testing.expectEqual(1, cloned.a);
|
||||
testing.expectEqual(2, cloned.b.c);
|
||||
cloned.b.c = 99;
|
||||
testing.expectEqual(2, obj.b.c); // original unchanged
|
||||
|
||||
// Array deep clone
|
||||
const arr = [1, [2, 3]];
|
||||
const clonedArr = structuredClone(arr);
|
||||
testing.expectEqual(1, clonedArr[0]);
|
||||
testing.expectEqual(2, clonedArr[1][0]);
|
||||
clonedArr[1][0] = 99;
|
||||
testing.expectEqual(2, arr[1][0]); // original unchanged
|
||||
</script>
|
||||
|
||||
<script id=screen>
|
||||
testing.expectEqual(1920, screen.width);
|
||||
testing.expectEqual(1080, screen.height);
|
||||
|
||||
@@ -76,13 +76,11 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
|
||||
}
|
||||
|
||||
// Dispatch abort event
|
||||
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
|
||||
try page._event_manager.dispatchDirect(
|
||||
self.asEventTarget(),
|
||||
event,
|
||||
self._on_abort,
|
||||
.{ .context = "abort signal" },
|
||||
);
|
||||
const target = self.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) {
|
||||
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
|
||||
try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" });
|
||||
}
|
||||
}
|
||||
|
||||
// Static method to create an already-aborted signal
|
||||
|
||||
@@ -33,6 +33,9 @@ _start_offset: u32,
|
||||
_end_container: *Node,
|
||||
_start_container: *Node,
|
||||
|
||||
// Intrusive linked list node for tracking live ranges on the Page.
|
||||
_range_link: std.DoublyLinkedList.Node = .{},
|
||||
|
||||
pub const Type = union(enum) {
|
||||
range: *Range,
|
||||
// TODO: static_range: *StaticRange,
|
||||
@@ -215,6 +218,91 @@ fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
|
||||
return isAncestorOf(potential_ancestor, node);
|
||||
}
|
||||
|
||||
/// Update this range's boundaries after a replaceData mutation on target.
|
||||
/// All parameters are in UTF-16 code unit offsets.
|
||||
pub fn updateForCharacterDataReplace(self: *AbstractRange, target: *Node, offset: u32, count: u32, data_len: u32) void {
|
||||
if (self._start_container == target) {
|
||||
if (self._start_offset > offset and self._start_offset <= offset + count) {
|
||||
self._start_offset = offset;
|
||||
} else if (self._start_offset > offset + count) {
|
||||
// Use i64 intermediate to avoid u32 underflow when count > data_len
|
||||
self._start_offset = @intCast(@as(i64, self._start_offset) + @as(i64, data_len) - @as(i64, count));
|
||||
}
|
||||
}
|
||||
|
||||
if (self._end_container == target) {
|
||||
if (self._end_offset > offset and self._end_offset <= offset + count) {
|
||||
self._end_offset = offset;
|
||||
} else if (self._end_offset > offset + count) {
|
||||
self._end_offset = @intCast(@as(i64, self._end_offset) + @as(i64, data_len) - @as(i64, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update this range's boundaries after a splitText operation.
|
||||
/// Steps 7b-7e of the DOM spec splitText algorithm.
|
||||
pub fn updateForSplitText(self: *AbstractRange, target: *Node, new_node: *Node, offset: u32, parent: *Node, node_index: u32) void {
|
||||
// Step 7b: ranges on the original node with start > offset move to new node
|
||||
if (self._start_container == target and self._start_offset > offset) {
|
||||
self._start_container = new_node;
|
||||
self._start_offset = self._start_offset - offset;
|
||||
}
|
||||
// Step 7c: ranges on the original node with end > offset move to new node
|
||||
if (self._end_container == target and self._end_offset > offset) {
|
||||
self._end_container = new_node;
|
||||
self._end_offset = self._end_offset - offset;
|
||||
}
|
||||
// Step 7d: ranges on parent with start == node_index + 1 increment
|
||||
if (self._start_container == parent and self._start_offset == node_index + 1) {
|
||||
self._start_offset += 1;
|
||||
}
|
||||
// Step 7e: ranges on parent with end == node_index + 1 increment
|
||||
if (self._end_container == parent and self._end_offset == node_index + 1) {
|
||||
self._end_offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update this range's boundaries after a node insertion.
|
||||
pub fn updateForNodeInsertion(self: *AbstractRange, parent: *Node, child_index: u32) void {
|
||||
if (self._start_container == parent and self._start_offset > child_index) {
|
||||
self._start_offset += 1;
|
||||
}
|
||||
if (self._end_container == parent and self._end_offset > child_index) {
|
||||
self._end_offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update this range's boundaries after a node removal.
|
||||
pub fn updateForNodeRemoval(self: *AbstractRange, parent: *Node, child: *Node, child_index: u32) void {
|
||||
// Steps 4-5: ranges whose start/end is an inclusive descendant of child
|
||||
// get moved to (parent, child_index).
|
||||
if (isInclusiveDescendantOf(self._start_container, child)) {
|
||||
self._start_container = parent;
|
||||
self._start_offset = child_index;
|
||||
}
|
||||
if (isInclusiveDescendantOf(self._end_container, child)) {
|
||||
self._end_container = parent;
|
||||
self._end_offset = child_index;
|
||||
}
|
||||
|
||||
// Steps 6-7: ranges on parent at offsets > child_index get decremented.
|
||||
if (self._start_container == parent and self._start_offset > child_index) {
|
||||
self._start_offset -= 1;
|
||||
}
|
||||
if (self._end_container == parent and self._end_offset > child_index) {
|
||||
self._end_offset -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn isInclusiveDescendantOf(node: *Node, potential_ancestor: *Node) bool {
|
||||
var current: ?*Node = node;
|
||||
while (current) |n| {
|
||||
if (n == potential_ancestor) return true;
|
||||
current = n.parentNode();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(AbstractRange);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ const Writer = std.Io.Writer;
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Mime = @import("../Mime.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// https://w3c.github.io/FileAPI/#blob-section
|
||||
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
|
||||
@@ -30,6 +35,8 @@ pub const _prototype_root = true;
|
||||
|
||||
_type: Type,
|
||||
|
||||
_arena: Allocator,
|
||||
|
||||
/// Immutable slice of blob.
|
||||
/// Note that another blob may hold a pointer/slice to this,
|
||||
/// so its better to leave the deallocation of it to arena allocator.
|
||||
@@ -50,26 +57,58 @@ const InitOptions = struct {
|
||||
endings: []const u8 = "transparent",
|
||||
};
|
||||
|
||||
/// Creates a new Blob.
|
||||
/// Creates a new Blob (JS constructor).
|
||||
pub fn init(
|
||||
maybe_blob_parts: ?[]const []const u8,
|
||||
maybe_options: ?InitOptions,
|
||||
page: *Page,
|
||||
) !*Blob {
|
||||
return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page);
|
||||
}
|
||||
|
||||
/// Creates a new Blob with optional MIME validation.
|
||||
/// When validate_mime is true, uses full MIME parsing (for Response/Request).
|
||||
/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor).
|
||||
pub fn initWithMimeValidation(
|
||||
maybe_blob_parts: ?[]const []const u8,
|
||||
maybe_options: ?InitOptions,
|
||||
validate_mime: bool,
|
||||
page: *Page,
|
||||
) !*Blob {
|
||||
const arena = try page.getArena(.{ .debug = "Blob" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const options: InitOptions = maybe_options orelse .{};
|
||||
// Setup MIME; This can be any string according to my observations.
|
||||
|
||||
const mime: []const u8 = blk: {
|
||||
const t = options.type;
|
||||
if (t.len == 0) {
|
||||
break :blk "";
|
||||
}
|
||||
|
||||
break :blk try page.arena.dupe(u8, t);
|
||||
const buf = try arena.dupe(u8, t);
|
||||
|
||||
if (validate_mime) {
|
||||
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
|
||||
_ = Mime.parse(buf) catch break :blk "";
|
||||
} else {
|
||||
// Simple validation per FileAPI spec (for Blob constructor):
|
||||
// - If any char is outside U+0020-U+007E, return empty string
|
||||
// - Otherwise lowercase
|
||||
for (t) |c| {
|
||||
if (c < 0x20 or c > 0x7E) {
|
||||
break :blk "";
|
||||
}
|
||||
}
|
||||
_ = std.ascii.lowerString(buf, buf);
|
||||
}
|
||||
|
||||
break :blk buf;
|
||||
};
|
||||
|
||||
const data = blk: {
|
||||
if (maybe_blob_parts) |blob_parts| {
|
||||
var w: Writer.Allocating = .init(page.arena);
|
||||
var w: Writer.Allocating = .init(arena);
|
||||
const use_native_endings = std.mem.eql(u8, options.endings, "native");
|
||||
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
|
||||
|
||||
@@ -79,11 +118,19 @@ pub fn init(
|
||||
break :blk "";
|
||||
};
|
||||
|
||||
return page._factory.create(Blob{
|
||||
const self = try arena.create(Blob);
|
||||
self.* = .{
|
||||
._arena = arena,
|
||||
._type = .generic,
|
||||
._slice = data,
|
||||
._mime = mime,
|
||||
});
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Blob, shutdown: bool, session: *Session) void {
|
||||
_ = shutdown;
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8);
|
||||
@@ -234,57 +281,31 @@ pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
|
||||
/// from a subset of the blob on which it's called.
|
||||
pub fn slice(
|
||||
self: *const Blob,
|
||||
maybe_start: ?i32,
|
||||
maybe_end: ?i32,
|
||||
maybe_content_type: ?[]const u8,
|
||||
start_: ?i32,
|
||||
end_: ?i32,
|
||||
content_type_: ?[]const u8,
|
||||
page: *Page,
|
||||
) !*Blob {
|
||||
const mime: []const u8 = blk: {
|
||||
if (maybe_content_type) |content_type| {
|
||||
if (content_type.len == 0) {
|
||||
break :blk "";
|
||||
}
|
||||
const data = self._slice;
|
||||
|
||||
break :blk try page.dupeString(content_type);
|
||||
const start = blk: {
|
||||
const requested_start = start_ orelse break :blk 0;
|
||||
if (requested_start < 0) {
|
||||
break :blk data.len -| @abs(requested_start);
|
||||
}
|
||||
|
||||
break :blk "";
|
||||
break :blk @min(data.len, @as(u31, @intCast(requested_start)));
|
||||
};
|
||||
|
||||
const data = self._slice;
|
||||
if (maybe_start) |_start| {
|
||||
const start = blk: {
|
||||
if (_start < 0) {
|
||||
break :blk data.len -| @abs(_start);
|
||||
}
|
||||
const end: usize = blk: {
|
||||
const requested_end = end_ orelse break :blk data.len;
|
||||
if (requested_end < 0) {
|
||||
break :blk @max(start, data.len -| @abs(requested_end));
|
||||
}
|
||||
|
||||
break :blk @min(data.len, @as(u31, @intCast(_start)));
|
||||
};
|
||||
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
|
||||
};
|
||||
|
||||
const end: usize = blk: {
|
||||
if (maybe_end) |_end| {
|
||||
if (_end < 0) {
|
||||
break :blk @max(start, data.len -| @abs(_end));
|
||||
}
|
||||
|
||||
break :blk @min(data.len, @max(start, @as(u31, @intCast(_end))));
|
||||
}
|
||||
|
||||
break :blk data.len;
|
||||
};
|
||||
|
||||
return page._factory.create(Blob{
|
||||
._type = .generic,
|
||||
._slice = data[start..end],
|
||||
._mime = mime,
|
||||
});
|
||||
}
|
||||
|
||||
return page._factory.create(Blob{
|
||||
._type = .generic,
|
||||
._slice = data,
|
||||
._mime = mime,
|
||||
});
|
||||
return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page);
|
||||
}
|
||||
|
||||
/// Returns the size of the Blob in bytes.
|
||||
@@ -304,6 +325,8 @@ pub const JsApi = struct {
|
||||
pub const name = "Blob";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(Blob.deinit);
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(Blob.init, .{});
|
||||
|
||||
@@ -37,7 +37,7 @@ _data: String = .empty,
|
||||
/// Count UTF-16 code units in a UTF-8 string.
|
||||
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
|
||||
/// everything else produces 1.
|
||||
fn utf16Len(data: []const u8) usize {
|
||||
pub fn utf16Len(data: []const u8) usize {
|
||||
var count: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i < data.len) {
|
||||
@@ -232,14 +232,13 @@ pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
|
||||
}
|
||||
|
||||
/// JS bridge wrapper for `data` setter.
|
||||
/// Handles [LegacyNullToEmptyString]: null → setData(null) → "".
|
||||
/// Passes everything else (including undefined) through V8 toString,
|
||||
/// so `undefined` becomes the string "undefined" per spec.
|
||||
/// Per spec, setting .data runs replaceData(0, this.length, value),
|
||||
/// which includes live range updates.
|
||||
/// Handles [LegacyNullToEmptyString]: null → "" per spec.
|
||||
pub fn _setData(self: *CData, value: js.Value, page: *Page) !void {
|
||||
if (value.isNull()) {
|
||||
return self.setData(null, page);
|
||||
}
|
||||
return self.setData(try value.toZig([]const u8), page);
|
||||
const new_value: []const u8 = if (value.isNull()) "" else try value.toZig([]const u8);
|
||||
const length = self.getLength();
|
||||
try self.replaceData(0, length, new_value, page);
|
||||
}
|
||||
|
||||
pub fn format(self: *const CData, writer: *std.io.Writer) !void {
|
||||
@@ -272,15 +271,20 @@ pub fn isEqualNode(self: *const CData, other: *const CData) bool {
|
||||
}
|
||||
|
||||
pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
|
||||
const old_value = self._data;
|
||||
self._data = try String.concat(page.arena, &.{ self._data.str(), data });
|
||||
page.characterDataChange(self.asNode(), old_value);
|
||||
// Per DOM spec, appendData(data) is replaceData(length, 0, data).
|
||||
const length = self.getLength();
|
||||
try self.replaceData(length, 0, data, page);
|
||||
}
|
||||
|
||||
pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
|
||||
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
|
||||
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
|
||||
|
||||
// Update live ranges per DOM spec replaceData steps (deleteData = replaceData with data="")
|
||||
const length = self.getLength();
|
||||
const effective_count: u32 = @intCast(@min(count, length - offset));
|
||||
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, 0);
|
||||
|
||||
const old_data = self._data;
|
||||
const old_value = old_data.str();
|
||||
if (range.start == 0) {
|
||||
@@ -299,6 +303,10 @@ pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void
|
||||
|
||||
pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
|
||||
const byte_offset = try utf16OffsetToUtf8(self._data.str(), offset);
|
||||
|
||||
// Update live ranges per DOM spec replaceData steps (insertData = replaceData with count=0)
|
||||
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), 0, @intCast(utf16Len(data)));
|
||||
|
||||
const old_value = self._data;
|
||||
const existing = old_value.str();
|
||||
self._data = try String.concat(page.arena, &.{
|
||||
@@ -312,6 +320,12 @@ pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !v
|
||||
pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
|
||||
const end_utf16 = std.math.add(usize, offset, count) catch std.math.maxInt(usize);
|
||||
const range = try utf16RangeToUtf8(self._data.str(), offset, end_utf16);
|
||||
|
||||
// Update live ranges per DOM spec replaceData steps
|
||||
const length = self.getLength();
|
||||
const effective_count: u32 = @intCast(@min(count, length - offset));
|
||||
page.updateRangesForCharacterDataReplace(self.asNode(), @intCast(offset), effective_count, @intCast(utf16Len(data)));
|
||||
|
||||
const old_value = self._data;
|
||||
const existing = old_value.str();
|
||||
self._data = try String.concat(page.arena, &.{
|
||||
|
||||
@@ -90,15 +90,16 @@ pub fn parseFromString(
|
||||
return pe.err;
|
||||
}
|
||||
|
||||
// If first node is a `ProcessingInstruction`, skip it.
|
||||
const first_child = doc_node.firstChild() orelse {
|
||||
// Parsing should fail if there aren't any nodes.
|
||||
unreachable;
|
||||
// Empty XML or no root element - this is a parse error.
|
||||
// TODO: Return a document with a <parsererror> element per spec.
|
||||
return error.JsException;
|
||||
};
|
||||
|
||||
// If first node is a `ProcessingInstruction`, skip it.
|
||||
if (first_child.getNodeType() == 7) {
|
||||
// We're sure that firstChild exist, this cannot fail.
|
||||
_ = doc_node.removeChild(first_child, page) catch unreachable;
|
||||
_ = try doc_node.removeChild(first_child, page);
|
||||
}
|
||||
|
||||
return doc.asDocument();
|
||||
|
||||
@@ -40,6 +40,8 @@ const Selection = @import("Selection.zig");
|
||||
pub const XMLDocument = @import("XMLDocument.zig");
|
||||
pub const HTMLDocument = @import("HTMLDocument.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Document = @This();
|
||||
|
||||
_type: Type,
|
||||
@@ -937,6 +939,32 @@ fn validateElementName(name: []const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// When a page or frame's URL is about:blank, or as soon as a frame is
|
||||
// programmatically created, it has this default "blank" content
|
||||
pub fn injectBlank(self: *Document, page: *Page) error{InjectBlankError}!void {
|
||||
self._injectBlank(page) catch |err| {
|
||||
// we wrap _injectBlank like this so that injectBlank can only return an
|
||||
// InjectBlankError. injectBlank is used in when nodes are inserted
|
||||
// as since it inserts node itself, Zig can't infer the error set.
|
||||
log.err(.browser, "inject blank", .{ .err = err });
|
||||
return error.InjectBlankError;
|
||||
};
|
||||
}
|
||||
|
||||
fn _injectBlank(self: *Document, page: *Page) !void {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should only be called on an empty document
|
||||
std.debug.assert(self.asNode()._children == null);
|
||||
}
|
||||
|
||||
const html = try page.createElementNS(.html, "html", null);
|
||||
const head = try page.createElementNS(.html, "head", null);
|
||||
const body = try page.createElementNS(.html, "body", null);
|
||||
try page.appendNode(html, head, .{});
|
||||
try page.appendNode(html, body, .{});
|
||||
try page.appendNode(self.asNode(), html, .{});
|
||||
}
|
||||
|
||||
const ReadyState = enum {
|
||||
loading,
|
||||
interactive,
|
||||
|
||||
@@ -195,8 +195,9 @@ pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node {
|
||||
|
||||
var child_it = node.childrenIterator();
|
||||
while (child_it.next()) |child| {
|
||||
const cloned_child = try child.cloneNode(true, page);
|
||||
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
|
||||
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
|
||||
try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 {
|
||||
.custom => |e| e._tag_name.str(),
|
||||
.data => "data",
|
||||
.datalist => "datalist",
|
||||
.details => "details",
|
||||
.dialog => "dialog",
|
||||
.directory => "dir",
|
||||
.div => "div",
|
||||
@@ -287,6 +288,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
|
||||
.custom => |e| upperTagName(&e._tag_name, buf),
|
||||
.data => "DATA",
|
||||
.datalist => "DATALIST",
|
||||
.details => "DETAILS",
|
||||
.dialog => "DIALOG",
|
||||
.directory => "DIR",
|
||||
.div => "DIV",
|
||||
@@ -1326,11 +1328,12 @@ pub fn clone(self: *Element, deep: bool, page: *Page) !*Node {
|
||||
if (deep) {
|
||||
var child_it = self.asNode().childrenIterator();
|
||||
while (child_it.next()) |child| {
|
||||
const cloned_child = try child.cloneNode(true, page);
|
||||
// We pass `true` to `child_already_connected` as a hacky optimization
|
||||
// We _know_ this child isn't connected (Becasue the parent isn't connected)
|
||||
// setting this to `true` skips all connection checks and just assumes t
|
||||
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
|
||||
if (try child.cloneNodeForAppending(true, page)) |cloned_child| {
|
||||
// We pass `true` to `child_already_connected` as a hacky optimization
|
||||
// We _know_ this child isn't connected (Because the parent isn't connected)
|
||||
// setting this to `true` skips all connection checks.
|
||||
try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,6 +1388,7 @@ pub fn getTag(self: *const Element) Tag {
|
||||
.custom => .custom,
|
||||
.data => .data,
|
||||
.datalist => .datalist,
|
||||
.details => .details,
|
||||
.dialog => .dialog,
|
||||
.directory => .directory,
|
||||
.iframe => .iframe,
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const Node = @import("Node.zig");
|
||||
const String = @import("../../string.zig").String;
|
||||
@@ -139,9 +140,9 @@ pub fn acquireRef(self: *Event) void {
|
||||
self._rc += 1;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
|
||||
pub fn deinit(self: *Event, shutdown: bool, session: *Session) void {
|
||||
if (shutdown) {
|
||||
page.releaseArena(self._arena);
|
||||
session.releaseArena(self._arena);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -151,7 +152,7 @@ pub fn deinit(self: *Event, shutdown: bool, page: *Page) void {
|
||||
}
|
||||
|
||||
if (rc == 1) {
|
||||
page.releaseArena(self._arena);
|
||||
session.releaseArena(self._arena);
|
||||
} else {
|
||||
self._rc = rc - 1;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
|
||||
event._is_trusted = false;
|
||||
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, page);
|
||||
defer event.deinit(false, page._session);
|
||||
try page._event_manager.dispatch(self, event);
|
||||
return !event._cancelable or !event._prevent_default;
|
||||
}
|
||||
@@ -138,6 +138,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
|
||||
.screen => writer.writeAll("<Screen>"),
|
||||
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
|
||||
.visual_viewport => writer.writeAll("<VisualViewport>"),
|
||||
.file_reader => writer.writeAll("<FileReader>"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Blob = @import("Blob.zig");
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Blob = @import("Blob.zig");
|
||||
|
||||
const File = @This();
|
||||
|
||||
@@ -29,7 +31,13 @@ _proto: *Blob,
|
||||
|
||||
// TODO: Implement File API.
|
||||
pub fn init(page: *Page) !*File {
|
||||
return page._factory.blob(File{ ._proto = undefined });
|
||||
const arena = try page.getArena(.{ .debug = "File" });
|
||||
errdefer page.releaseArena(arena);
|
||||
return page._factory.blob(arena, File{ ._proto = undefined });
|
||||
}
|
||||
|
||||
pub fn deinit(self: *File, shutdown: bool, session: *Session) void {
|
||||
self._proto.deinit(shutdown, session);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
@@ -39,6 +47,8 @@ pub const JsApi = struct {
|
||||
pub const name = "File";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(File.deinit);
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(File.init, .{});
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const EventTarget = @import("EventTarget.zig");
|
||||
const ProgressEvent = @import("event/ProgressEvent.zig");
|
||||
const Blob = @import("Blob.zig");
|
||||
@@ -69,17 +70,15 @@ pub fn init(page: *Page) !*FileReader {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn deinit(self: *FileReader, _: bool, page: *Page) void {
|
||||
const js_ctx = page.js;
|
||||
pub fn deinit(self: *FileReader, _: bool, session: *Session) void {
|
||||
if (self._on_abort) |func| func.release();
|
||||
if (self._on_error) |func| func.release();
|
||||
if (self._on_load) |func| func.release();
|
||||
if (self._on_load_end) |func| func.release();
|
||||
if (self._on_load_start) |func| func.release();
|
||||
if (self._on_progress) |func| func.release();
|
||||
|
||||
if (self._on_abort) |func| js_ctx.release(func);
|
||||
if (self._on_error) |func| js_ctx.release(func);
|
||||
if (self._on_load) |func| js_ctx.release(func);
|
||||
if (self._on_load_end) |func| js_ctx.release(func);
|
||||
if (self._on_load_start) |func| js_ctx.release(func);
|
||||
if (self._on_progress) |func| js_ctx.release(func);
|
||||
|
||||
page.releaseArena(self._arena);
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
fn asEventTarget(self: *FileReader) *EventTarget {
|
||||
|
||||
@@ -167,9 +167,8 @@ pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
|
||||
return collections.NodeLive(.tag).init(self.asNode(), .embed, page);
|
||||
}
|
||||
|
||||
const applet_string = String.init(undefined, "applet", .{}) catch unreachable;
|
||||
pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) {
|
||||
return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page);
|
||||
pub fn getApplets(_: *const HTMLDocument) collections.HTMLCollection {
|
||||
return .{ ._data = .empty };
|
||||
}
|
||||
|
||||
pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {
|
||||
@@ -180,8 +179,8 @@ pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") {
|
||||
return self._proto._location;
|
||||
}
|
||||
|
||||
pub fn setLocation(_: *const HTMLDocument, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
|
||||
pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page });
|
||||
}
|
||||
|
||||
pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection {
|
||||
|
||||
@@ -79,13 +79,11 @@ fn goInner(delta: i32, page: *Page) !void {
|
||||
|
||||
if (entry._url) |url| {
|
||||
if (try page.isSameOrigin(url)) {
|
||||
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
|
||||
try page._event_manager.dispatchDirect(
|
||||
page.window.asEventTarget(),
|
||||
event,
|
||||
page.window._on_popstate,
|
||||
.{ .context = "Pop State" },
|
||||
);
|
||||
const target = page.window.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) {
|
||||
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
|
||||
try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const DOMRect = @import("DOMRect.zig");
|
||||
|
||||
@@ -91,13 +92,13 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *IntersectionObserver, shutdown: bool, page: *Page) void {
|
||||
page.js.release(self._callback);
|
||||
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
|
||||
self._callback.release();
|
||||
if ((comptime IS_DEBUG) and !shutdown) {
|
||||
std.debug.assert(self._observing.items.len == 0);
|
||||
}
|
||||
|
||||
page.releaseArena(self._arena);
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
|
||||
@@ -137,7 +138,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
|
||||
while (j < self._pending_entries.items.len) {
|
||||
if (self._pending_entries.items[j]._target == target) {
|
||||
const entry = self._pending_entries.swapRemove(j);
|
||||
entry.deinit(false, page);
|
||||
entry.deinit(false, page._session);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
@@ -157,7 +158,7 @@ pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
|
||||
self._previous_states.clearRetainingCapacity();
|
||||
|
||||
for (self._pending_entries.items) |entry| {
|
||||
entry.deinit(false, page);
|
||||
entry.deinit(false, page._session);
|
||||
}
|
||||
self._pending_entries.clearRetainingCapacity();
|
||||
page.js.safeWeakRef(self);
|
||||
@@ -302,8 +303,8 @@ pub const IntersectionObserverEntry = struct {
|
||||
_intersection_ratio: f64,
|
||||
_is_intersecting: bool,
|
||||
|
||||
pub fn deinit(self: *const IntersectionObserverEntry, _: bool, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
pub fn deinit(self: *IntersectionObserverEntry, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn getTarget(self: *const IntersectionObserverEntry) *Element {
|
||||
|
||||
@@ -83,19 +83,19 @@ pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(normalized_hash, .{
|
||||
.reason = .script,
|
||||
.kind = .{ .replace = null },
|
||||
}, .script);
|
||||
}, .{ .script = page });
|
||||
}
|
||||
|
||||
pub fn assign(_: *const Location, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = page });
|
||||
}
|
||||
|
||||
pub fn replace(_: *const Location, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .script);
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .replace = null } }, .{ .script = page });
|
||||
}
|
||||
|
||||
pub fn reload(_: *const Location, page: *Page) !void {
|
||||
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .script);
|
||||
return page.scheduleNavigation(page.url, .{ .reason = .script, .kind = .reload }, .{ .script = page });
|
||||
}
|
||||
|
||||
pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 {
|
||||
|
||||
@@ -122,23 +122,21 @@ const PostMessageCallback = struct {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
|
||||
.data = self.message,
|
||||
.origin = "",
|
||||
.source = null,
|
||||
}, page) catch |err| {
|
||||
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
|
||||
return null;
|
||||
}).asEvent();
|
||||
const target = self.port.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
|
||||
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
|
||||
.data = self.message,
|
||||
.origin = "",
|
||||
.source = null,
|
||||
}, page) catch |err| {
|
||||
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
|
||||
return null;
|
||||
}).asEvent();
|
||||
|
||||
page._event_manager.dispatchDirect(
|
||||
self.port.asEventTarget(),
|
||||
event,
|
||||
self.port._on_message,
|
||||
.{ .context = "MessagePort message" },
|
||||
) catch |err| {
|
||||
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
|
||||
};
|
||||
page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| {
|
||||
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const String = @import("../../string.zig").String;
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const Node = @import("Node.zig");
|
||||
const Element = @import("Element.zig");
|
||||
const log = @import("../../log.zig");
|
||||
@@ -84,13 +85,13 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *MutationObserver, shutdown: bool, page: *Page) void {
|
||||
page.js.release(self._callback);
|
||||
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
|
||||
self._callback.release();
|
||||
if ((comptime IS_DEBUG) and !shutdown) {
|
||||
std.debug.assert(self._observing.items.len == 0);
|
||||
}
|
||||
|
||||
page.releaseArena(self._arena);
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
|
||||
@@ -171,7 +172,7 @@ pub fn disconnect(self: *MutationObserver, page: *Page) void {
|
||||
page.unregisterMutationObserver(self);
|
||||
self._observing.clearRetainingCapacity();
|
||||
for (self._pending_records.items) |record| {
|
||||
record.deinit(false, page);
|
||||
record.deinit(false, page._session);
|
||||
}
|
||||
self._pending_records.clearRetainingCapacity();
|
||||
page.js.safeWeakRef(self);
|
||||
@@ -363,8 +364,8 @@ pub const MutationRecord = struct {
|
||||
characterData,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *const MutationRecord, _: bool, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
pub fn deinit(self: *MutationRecord, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn getType(self: *const MutationRecord) []const u8 {
|
||||
|
||||
@@ -293,7 +293,8 @@ pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
|
||||
}
|
||||
return el.replaceChildren(&.{.{ .text = data }}, page);
|
||||
},
|
||||
.cdata => |c| c._data = try page.dupeSSO(data),
|
||||
// Per spec, setting textContent on CharacterData runs replaceData(0, length, value)
|
||||
.cdata => |c| try c.replaceData(0, c.getLength(), data, page),
|
||||
.document => {},
|
||||
.document_type => {},
|
||||
.document_fragment => |frag| {
|
||||
@@ -612,7 +613,11 @@ pub fn getNodeValue(self: *const Node) ?String {
|
||||
|
||||
pub fn setNodeValue(self: *const Node, value: ?String, page: *Page) !void {
|
||||
switch (self._type) {
|
||||
.cdata => |c| try c.setData(if (value) |v| v.str() else null, page),
|
||||
// Per spec, setting nodeValue on CharacterData runs replaceData(0, length, value)
|
||||
.cdata => |c| {
|
||||
const new_value: []const u8 = if (value) |v| v.str() else "";
|
||||
try c.replaceData(0, c.getLength(), new_value, page);
|
||||
},
|
||||
.attribute => |attr| try attr.setValue(value, page),
|
||||
.element => {},
|
||||
.document => {},
|
||||
@@ -724,6 +729,9 @@ const CloneError = error{
|
||||
TooManyContexts,
|
||||
LinkLoadError,
|
||||
StyleLoadError,
|
||||
TypeError,
|
||||
CompilationError,
|
||||
JsException,
|
||||
};
|
||||
pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
|
||||
const deep = deep_ orelse false;
|
||||
@@ -751,6 +759,29 @@ pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) CloneError!*Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a node for the purpose of appending to a parent.
|
||||
/// Returns null if the cloned node was already attached somewhere by a custom element
|
||||
/// constructor, indicating that the constructor's decision should be respected.
|
||||
///
|
||||
/// This helper is used when iterating over children to clone them. The typical pattern is:
|
||||
/// while (child_it.next()) |child| {
|
||||
/// if (try child.cloneNodeForAppending(true, page)) |cloned| {
|
||||
/// try page.appendNode(parent, cloned, opts);
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// The only case where a cloned node would already have a parent is when a custom element
|
||||
/// constructor (which runs during cloning per the HTML spec) explicitly attaches the element
|
||||
/// somewhere. In that case, we respect the constructor's decision and return null to signal
|
||||
/// that the cloned node should not be appended to our intended parent.
|
||||
pub fn cloneNodeForAppending(self: *Node, deep: bool, page: *Page) CloneError!?*Node {
|
||||
const cloned = try self.cloneNode(deep, page);
|
||||
if (cloned._parent != null) {
|
||||
return null;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
pub fn compareDocumentPosition(self: *Node, other: *Node) u16 {
|
||||
const DISCONNECTED: u16 = 0x01;
|
||||
const PRECEDING: u16 = 0x02;
|
||||
|
||||
@@ -268,7 +268,7 @@ pub const JsApi = struct {
|
||||
|
||||
pub const now = bridge.function(Performance.now, .{});
|
||||
pub const mark = bridge.function(Performance.mark, .{});
|
||||
pub const measure = bridge.function(Performance.measure, .{});
|
||||
pub const measure = bridge.function(Performance.measure, .{ .dom_exception = true });
|
||||
pub const clearMarks = bridge.function(Performance.clearMarks, .{});
|
||||
pub const clearMeasures = bridge.function(Performance.clearMeasures, .{});
|
||||
pub const getEntries = bridge.function(Performance.getEntries, .{});
|
||||
|
||||
@@ -25,6 +25,7 @@ const Page = @import("../Page.zig");
|
||||
const Node = @import("Node.zig");
|
||||
const DocumentFragment = @import("DocumentFragment.zig");
|
||||
const AbstractRange = @import("AbstractRange.zig");
|
||||
const DOMRect = @import("DOMRect.zig");
|
||||
|
||||
const Range = @This();
|
||||
|
||||
@@ -321,6 +322,11 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
|
||||
const container = self._proto._start_container;
|
||||
const offset = self._proto._start_offset;
|
||||
|
||||
// Per spec: if range is collapsed, end offset should extend to include
|
||||
// the inserted node. Capture before insertion since live range updates
|
||||
// in the insert path will adjust non-collapsed ranges automatically.
|
||||
const was_collapsed = self._proto.getCollapsed();
|
||||
|
||||
if (container.is(Node.CData)) |_| {
|
||||
// If container is a text node, we need to split it
|
||||
const parent = container.parentNode() orelse return error.InvalidNodeType;
|
||||
@@ -350,9 +356,10 @@ pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
|
||||
_ = try container.insertBefore(node, ref_child, page);
|
||||
}
|
||||
|
||||
// Update range to be after the inserted node
|
||||
if (self._proto._start_container == self._proto._end_container) {
|
||||
self._proto._end_offset += 1;
|
||||
// Per spec step 11: if range was collapsed, extend end to include inserted node.
|
||||
// Non-collapsed ranges are already handled by the live range update in the insert path.
|
||||
if (was_collapsed) {
|
||||
self._proto._end_offset = self._proto._start_offset + 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,9 +381,12 @@ pub fn deleteContents(self: *Range, page: *Page) !void {
|
||||
);
|
||||
page.characterDataChange(self._proto._start_container, old_value);
|
||||
} else {
|
||||
// Delete child nodes in range
|
||||
var offset = self._proto._start_offset;
|
||||
while (offset < self._proto._end_offset) : (offset += 1) {
|
||||
// Delete child nodes in range.
|
||||
// Capture count before the loop: removeChild triggers live range
|
||||
// updates that decrement _end_offset on each removal.
|
||||
const count = self._proto._end_offset - self._proto._start_offset;
|
||||
var i: u32 = 0;
|
||||
while (i < count) : (i += 1) {
|
||||
if (self._proto._start_container.getChildAt(self._proto._start_offset)) |child| {
|
||||
_ = try self._proto._start_container.removeChild(child, page);
|
||||
}
|
||||
@@ -446,8 +456,9 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
|
||||
var offset = self._proto._start_offset;
|
||||
while (offset < self._proto._end_offset) : (offset += 1) {
|
||||
if (self._proto._start_container.getChildAt(offset)) |child| {
|
||||
const cloned = try child.cloneNode(true, page);
|
||||
_ = try fragment.asNode().appendChild(cloned, page);
|
||||
if (try child.cloneNodeForAppending(true, page)) |cloned| {
|
||||
_ = try fragment.asNode().appendChild(cloned, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,9 +479,11 @@ pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
|
||||
if (self._proto._start_container.parentNode() == self._proto._end_container.parentNode()) {
|
||||
var current = self._proto._start_container.nextSibling();
|
||||
while (current != null and current != self._proto._end_container) {
|
||||
const cloned = try current.?.cloneNode(true, page);
|
||||
_ = try fragment.asNode().appendChild(cloned, page);
|
||||
current = current.?.nextSibling();
|
||||
const next = current.?.nextSibling();
|
||||
if (try current.?.cloneNodeForAppending(true, page)) |cloned| {
|
||||
_ = try fragment.asNode().appendChild(cloned, page);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +653,33 @@ fn nextAfterSubtree(node: *Node, root: *Node) ?*Node {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getBoundingClientRect(self: *const Range, page: *Page) DOMRect {
|
||||
if (self._proto.getCollapsed()) {
|
||||
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
|
||||
}
|
||||
const element = self.getContainerElement() orelse {
|
||||
return .{ ._x = 0, ._y = 0, ._width = 0, ._height = 0 };
|
||||
};
|
||||
return element.getBoundingClientRect(page);
|
||||
}
|
||||
|
||||
pub fn getClientRects(self: *const Range, page: *Page) ![]DOMRect {
|
||||
if (self._proto.getCollapsed()) {
|
||||
return &.{};
|
||||
}
|
||||
const element = self.getContainerElement() orelse {
|
||||
return &.{};
|
||||
};
|
||||
return element.getClientRects(page);
|
||||
}
|
||||
|
||||
fn getContainerElement(self: *const Range) ?*Node.Element {
|
||||
const container = self._proto.getCommonAncestorContainer();
|
||||
if (container.is(Node.Element)) |el| return el;
|
||||
const parent = container.parentNode() orelse return null;
|
||||
return parent.is(Node.Element);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Range);
|
||||
|
||||
@@ -678,9 +718,14 @@ pub const JsApi = struct {
|
||||
pub const surroundContents = bridge.function(Range.surroundContents, .{ .dom_exception = true });
|
||||
pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{ .dom_exception = true });
|
||||
pub const toString = bridge.function(Range.toString, .{ .dom_exception = true });
|
||||
pub const getBoundingClientRect = bridge.function(Range.getBoundingClientRect, .{});
|
||||
pub const getClientRects = bridge.function(Range.getClientRects, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "WebApi: Range" {
|
||||
try testing.htmlRunner("range.html", .{});
|
||||
}
|
||||
test "WebApi: Range mutations" {
|
||||
try testing.htmlRunner("range_mutations.html", .{});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const Mode = enum {
|
||||
|
||||
pub fn TreeWalker(comptime mode: Mode) type {
|
||||
return struct {
|
||||
_current: ?*Node = null,
|
||||
_next: ?*Node,
|
||||
_root: *Node,
|
||||
|
||||
@@ -47,37 +48,46 @@ pub fn TreeWalker(comptime mode: Mode) type {
|
||||
|
||||
pub fn next(self: *Self) ?*Node {
|
||||
const node = self._next orelse return null;
|
||||
self._current = node;
|
||||
|
||||
if (comptime mode == .children) {
|
||||
self._next = Node.linkToNodeOrNull(node._child_link.next);
|
||||
self._next = node.nextSibling();
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node._children) |children| {
|
||||
self._next = children.first();
|
||||
} else if (node._child_link.next) |n| {
|
||||
self._next = Node.linkToNode(n);
|
||||
if (node.firstChild()) |child| {
|
||||
self._next = child;
|
||||
} else {
|
||||
// No children, no next sibling - walk up until we find a next sibling or hit root
|
||||
var current = node._parent;
|
||||
while (current) |parent| {
|
||||
if (parent == self._root) {
|
||||
self._next = null;
|
||||
break;
|
||||
var current: *Node = node;
|
||||
while (current != self._root) {
|
||||
if (current.nextSibling()) |sibling| {
|
||||
self._next = sibling;
|
||||
return node;
|
||||
}
|
||||
if (parent._child_link.next) |next_sibling| {
|
||||
self._next = Node.linkToNode(next_sibling);
|
||||
break;
|
||||
}
|
||||
current = parent._parent;
|
||||
} else {
|
||||
self._next = null;
|
||||
current = current._parent orelse break;
|
||||
}
|
||||
self._next = null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
pub fn skipChildren(self: *Self) void {
|
||||
if (comptime mode == .children) return;
|
||||
const current_node = self._current orelse return;
|
||||
|
||||
var current: *Node = current_node;
|
||||
while (current != self._root) {
|
||||
if (current.nextSibling()) |sibling| {
|
||||
self._next = sibling;
|
||||
return;
|
||||
}
|
||||
current = current._parent orelse break;
|
||||
}
|
||||
self._next = null;
|
||||
}
|
||||
|
||||
pub fn reset(self: *Self) void {
|
||||
self._current = null;
|
||||
self._next = firstNext(self._root);
|
||||
}
|
||||
|
||||
@@ -147,3 +157,38 @@ pub fn TreeWalker(comptime mode: Mode) type {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
test "TreeWalker: skipChildren" {
|
||||
const testing = @import("../../testing.zig");
|
||||
const page = try testing.test_session.createPage();
|
||||
defer testing.test_session.removePage();
|
||||
const doc = page.window._document;
|
||||
|
||||
// <div>
|
||||
// <span>
|
||||
// <b>A</b>
|
||||
// </span>
|
||||
// <p>B</p>
|
||||
// </div>
|
||||
const div = try doc.createElement("div", null, page);
|
||||
const span = try doc.createElement("span", null, page);
|
||||
const b = try doc.createElement("b", null, page);
|
||||
const p = try doc.createElement("p", null, page);
|
||||
_ = try span.asNode().appendChild(b.asNode(), page);
|
||||
_ = try div.asNode().appendChild(span.asNode(), page);
|
||||
_ = try div.asNode().appendChild(p.asNode(), page);
|
||||
|
||||
var tw = Full.init(div.asNode(), .{});
|
||||
|
||||
// root (div)
|
||||
try testing.expect(tw.next() == div.asNode());
|
||||
|
||||
// span
|
||||
try testing.expect(tw.next() == span.asNode());
|
||||
|
||||
// skip children of span (should jump over <b> to <p>)
|
||||
tw.skipChildren();
|
||||
try testing.expect(tw.next() == p.asNode());
|
||||
|
||||
try testing.expect(tw.next() == null);
|
||||
}
|
||||
|
||||
@@ -66,10 +66,20 @@ pub fn getUsername(self: *const URL) []const u8 {
|
||||
return U.getUsername(self._raw);
|
||||
}
|
||||
|
||||
pub fn setUsername(self: *URL, value: []const u8) !void {
|
||||
const allocator = self._arena orelse return error.NoAllocator;
|
||||
self._raw = try U.setUsername(self._raw, value, allocator);
|
||||
}
|
||||
|
||||
pub fn getPassword(self: *const URL) []const u8 {
|
||||
return U.getPassword(self._raw);
|
||||
}
|
||||
|
||||
pub fn setPassword(self: *URL, value: []const u8) !void {
|
||||
const allocator = self._arena orelse return error.NoAllocator;
|
||||
self._raw = try U.setPassword(self._raw, value, allocator);
|
||||
}
|
||||
|
||||
pub fn getPathname(self: *const URL) []const u8 {
|
||||
return U.getPathname(self._raw);
|
||||
}
|
||||
@@ -233,11 +243,10 @@ pub fn createObjectURL(blob: *Blob, page: *Page) ![]const u8 {
|
||||
var uuid_buf: [36]u8 = undefined;
|
||||
@import("../../id.zig").uuidv4(&uuid_buf);
|
||||
|
||||
const origin = (try page.getOrigin(page.call_arena)) orelse "null";
|
||||
const blob_url = try std.fmt.allocPrint(
|
||||
page.arena,
|
||||
"blob:{s}/{s}",
|
||||
.{ origin, uuid_buf },
|
||||
.{ page.origin orelse "null", uuid_buf },
|
||||
);
|
||||
try page._blob_urls.put(page.arena, blob_url, blob);
|
||||
return blob_url;
|
||||
@@ -272,8 +281,8 @@ pub const JsApi = struct {
|
||||
pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{});
|
||||
pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{});
|
||||
pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{});
|
||||
pub const username = bridge.accessor(URL.getUsername, null, .{});
|
||||
pub const password = bridge.accessor(URL.getPassword, null, .{});
|
||||
pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{});
|
||||
pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{});
|
||||
pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{});
|
||||
pub const host = bridge.accessor(URL.getHost, URL.setHost, .{});
|
||||
pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});
|
||||
|
||||
@@ -160,8 +160,8 @@ pub fn getSelection(self: *const Window) *Selection {
|
||||
return &self._document._selection;
|
||||
}
|
||||
|
||||
pub fn setLocation(_: *const Window, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .script);
|
||||
pub fn setLocation(self: *Window, url: [:0]const u8, page: *Page) !void {
|
||||
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._page });
|
||||
}
|
||||
|
||||
pub fn getHistory(_: *Window, page: *Page) *History {
|
||||
@@ -412,6 +412,18 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
pub fn structuredClone(_: *const Window, value: js.Value) !js.Value {
|
||||
// Simplified structured clone using JSON round-trip.
|
||||
// Handles JSON-serializable types (objects, arrays, strings, numbers, booleans, null).
|
||||
const local = value.local;
|
||||
const str_handle = js.v8.v8__JSON__Stringify(local.handle, value.handle, null) orelse return error.DataCloneError;
|
||||
const cloned_handle = js.v8.v8__JSON__Parse(local.handle, str_handle) orelse return error.DataCloneError;
|
||||
return js.Value{
|
||||
.local = local,
|
||||
.handle = cloned_handle,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getFrame(self: *Window, idx: usize) !?*Window {
|
||||
const page = self._page;
|
||||
const frames = page.frames.items;
|
||||
@@ -551,17 +563,14 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
|
||||
});
|
||||
}
|
||||
|
||||
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
|
||||
.reason = if (rejection.reason()) |r| try r.temp() else null,
|
||||
.promise = try rejection.promise().temp(),
|
||||
}, page)).asEvent();
|
||||
|
||||
try page._event_manager.dispatchDirect(
|
||||
self.asEventTarget(),
|
||||
event,
|
||||
self._on_unhandled_rejection,
|
||||
.{ .inject_target = true, .context = "window.unhandledrejection" },
|
||||
);
|
||||
const target = self.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
|
||||
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
|
||||
.reason = if (rejection.reason()) |r| try r.temp() else null,
|
||||
.promise = try rejection.promise().temp(),
|
||||
}, page)).asEvent();
|
||||
try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
|
||||
}
|
||||
}
|
||||
|
||||
const ScheduleOpts = struct {
|
||||
@@ -649,9 +658,9 @@ const ScheduleCallback = struct {
|
||||
}
|
||||
|
||||
fn deinit(self: *ScheduleCallback) void {
|
||||
self.page.js.release(self.cb);
|
||||
self.cb.release();
|
||||
for (self.params) |param| {
|
||||
self.page.js.release(param);
|
||||
param.release();
|
||||
}
|
||||
self.page.releaseArena(self.arena);
|
||||
}
|
||||
@@ -798,8 +807,9 @@ pub const JsApi = struct {
|
||||
pub const matchMedia = bridge.function(Window.matchMedia, .{});
|
||||
pub const postMessage = bridge.function(Window.postMessage, .{});
|
||||
pub const btoa = bridge.function(Window.btoa, .{});
|
||||
pub const atob = bridge.function(Window.atob, .{});
|
||||
pub const atob = bridge.function(Window.atob, .{ .dom_exception = true });
|
||||
pub const reportError = bridge.function(Window.reportError, .{});
|
||||
pub const structuredClone = bridge.function(Window.structuredClone, .{});
|
||||
pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
|
||||
pub const getSelection = bridge.function(Window.getSelection, .{});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
const log = @import("../../../log.zig");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -61,8 +62,8 @@ pub fn init(page: *Page) !*Animation {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Animation, _: bool, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
pub fn deinit(self: *Animation, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn play(self: *Animation, page: *Page) !void {
|
||||
|
||||
@@ -43,16 +43,26 @@ pub fn splitText(self: *Text, offset: usize, page: *Page) !*Text {
|
||||
const new_node = try page.createTextNode(new_data);
|
||||
const new_text = new_node.as(Text);
|
||||
|
||||
const old_data = data[0..byte_offset];
|
||||
try self._proto.setData(old_data, page);
|
||||
|
||||
// If this node has a parent, insert the new node right after this one
|
||||
const node = self._proto.asNode();
|
||||
|
||||
// Per DOM spec splitText: insert first (step 7a), then update ranges (7b-7e),
|
||||
// then truncate original node (step 8).
|
||||
if (node.parentNode()) |parent| {
|
||||
const next_sibling = node.nextSibling();
|
||||
_ = try parent.insertBefore(new_node, next_sibling, page);
|
||||
|
||||
// splitText-specific range updates (steps 7b-7e)
|
||||
if (parent.getChildIndex(node)) |node_index| {
|
||||
page.updateRangesForSplitText(node, new_node, @intCast(offset), parent, node_index);
|
||||
}
|
||||
}
|
||||
|
||||
// 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, "", page);
|
||||
|
||||
return new_text;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,15 @@ pub const NodeLive = @import("collections/node_live.zig").NodeLive;
|
||||
pub const ChildNodes = @import("collections/ChildNodes.zig");
|
||||
pub const DOMTokenList = @import("collections/DOMTokenList.zig");
|
||||
pub const RadioNodeList = @import("collections/RadioNodeList.zig");
|
||||
pub const HTMLCollection = @import("collections/HTMLCollection.zig");
|
||||
pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig");
|
||||
pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig");
|
||||
pub const HTMLFormControlsCollection = @import("collections/HTMLFormControlsCollection.zig");
|
||||
|
||||
pub fn registerTypes() []const type {
|
||||
return &.{
|
||||
@import("collections/HTMLCollection.zig"),
|
||||
@import("collections/HTMLCollection.zig").Iterator,
|
||||
HTMLCollection,
|
||||
HTMLCollection.Iterator,
|
||||
@import("collections/NodeList.zig"),
|
||||
@import("collections/NodeList.zig").KeyIterator,
|
||||
@import("collections/NodeList.zig").ValueIterator,
|
||||
|
||||
@@ -20,6 +20,7 @@ const std = @import("std");
|
||||
|
||||
const Node = @import("../Node.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const GenericIterator = @import("iterator.zig").Entry;
|
||||
|
||||
// Optimized for node.childNodes, which has to be a live list.
|
||||
@@ -53,8 +54,8 @@ pub fn init(node: *Node, page: *Page) !*ChildNodes {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const ChildNodes, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
pub fn deinit(self: *const ChildNodes, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn length(self: *ChildNodes, page: *Page) !u32 {
|
||||
|
||||
@@ -36,6 +36,7 @@ const Mode = enum {
|
||||
links,
|
||||
anchors,
|
||||
form,
|
||||
empty,
|
||||
};
|
||||
|
||||
const HTMLCollection = @This();
|
||||
@@ -52,22 +53,26 @@ _data: union(Mode) {
|
||||
links: NodeLive(.links),
|
||||
anchors: NodeLive(.anchors),
|
||||
form: NodeLive(.form),
|
||||
empty: void,
|
||||
},
|
||||
|
||||
pub fn length(self: *HTMLCollection, page: *const Page) u32 {
|
||||
return switch (self._data) {
|
||||
.empty => 0,
|
||||
inline else => |*impl| impl.length(page),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element {
|
||||
return switch (self._data) {
|
||||
.empty => null,
|
||||
inline else => |*impl| impl.getAtIndex(index, page),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element {
|
||||
return switch (self._data) {
|
||||
.empty => null,
|
||||
inline else => |*impl| impl.getByName(name, page),
|
||||
};
|
||||
}
|
||||
@@ -87,6 +92,7 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
|
||||
.links => |*impl| .{ .links = impl._tw.clone() },
|
||||
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
|
||||
.form => |*impl| .{ .form = impl._tw.clone() },
|
||||
.empty => .empty,
|
||||
},
|
||||
}, page);
|
||||
}
|
||||
@@ -106,6 +112,7 @@ pub const Iterator = GenericIterator(struct {
|
||||
links: TreeWalker.FullExcludeSelf,
|
||||
anchors: TreeWalker.FullExcludeSelf,
|
||||
form: TreeWalker.FullExcludeSelf,
|
||||
empty: void,
|
||||
},
|
||||
|
||||
pub fn next(self: *@This(), _: *Page) ?*Element {
|
||||
@@ -121,6 +128,7 @@ pub const Iterator = GenericIterator(struct {
|
||||
.links => |*impl| impl.nextTw(&self.tw.links),
|
||||
.anchors => |*impl| impl.nextTw(&self.tw.anchors),
|
||||
.form => |*impl| impl.nextTw(&self.tw.form),
|
||||
.empty => return null,
|
||||
};
|
||||
}
|
||||
}, null);
|
||||
|
||||
@@ -21,6 +21,7 @@ const std = @import("std");
|
||||
const log = @import("../../../log.zig");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const Node = @import("../Node.zig");
|
||||
|
||||
const ChildNodes = @import("ChildNodes.zig");
|
||||
@@ -38,7 +39,7 @@ _data: union(enum) {
|
||||
},
|
||||
_rc: usize = 0,
|
||||
|
||||
pub fn deinit(self: *NodeList, _: bool, page: *Page) void {
|
||||
pub fn deinit(self: *NodeList, _: bool, session: *Session) void {
|
||||
const rc = self._rc;
|
||||
if (rc > 1) {
|
||||
self._rc = rc - 1;
|
||||
@@ -46,8 +47,8 @@ pub fn deinit(self: *NodeList, _: bool, page: *Page) void {
|
||||
}
|
||||
|
||||
switch (self._data) {
|
||||
.selector_list => |list| list.deinit(page),
|
||||
.child_nodes => |cn| cn.deinit(page),
|
||||
.selector_list => |list| list.deinit(session),
|
||||
.child_nodes => |cn| cn.deinit(session),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
@@ -118,8 +119,8 @@ const Iterator = struct {
|
||||
|
||||
const Entry = struct { u32, *Node };
|
||||
|
||||
pub fn deinit(self: *Iterator, shutdown: bool, page: *Page) void {
|
||||
self.list.deinit(shutdown, page);
|
||||
pub fn deinit(self: *Iterator, shutdown: bool, session: *Session) void {
|
||||
self.list.deinit(shutdown, session);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *Iterator) void {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
const std = @import("std");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
|
||||
pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
|
||||
const R = reflect(Inner, field);
|
||||
@@ -39,9 +40,9 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
|
||||
return page._factory.create(Self{ .inner = inner });
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self, shutdown: bool, page: *Page) void {
|
||||
pub fn deinit(self: *Self, shutdown: bool, session: *Session) void {
|
||||
if (@hasDecl(Inner, "deinit")) {
|
||||
self.inner.deinit(shutdown, page);
|
||||
self.inner.deinit(shutdown, session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,14 @@ pub fn NodeLive(comptime mode: Mode) type {
|
||||
switch (mode) {
|
||||
.tag => {
|
||||
const el = node.is(Element) orelse return false;
|
||||
return el.getTag() == self._filter;
|
||||
// For HTML namespace elements, we can use the optimized tag comparison.
|
||||
// For other namespaces (XML, SVG custom elements, etc.), fall back to string comparison.
|
||||
if (el._namespace == .html) {
|
||||
return el.getTag() == self._filter;
|
||||
}
|
||||
// For non-HTML elements, compare by tag name string
|
||||
const element_tag = el.getTagNameLower();
|
||||
return std.mem.eql(u8, element_tag, @tagName(self._filter));
|
||||
},
|
||||
.tag_name => {
|
||||
// If we're in `tag_name` mode, then the tag_name isn't
|
||||
|
||||
@@ -26,6 +26,8 @@ const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Element = @import("../Element.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const CSSStyleDeclaration = @This();
|
||||
|
||||
_element: ?*Element = null,
|
||||
@@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
|
||||
const normalized = normalizePropertyName(property_name, &page.buf);
|
||||
|
||||
// Normalize the value for canonical serialization
|
||||
const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value);
|
||||
|
||||
// Find existing property
|
||||
if (self.findProperty(normalized)) |existing| {
|
||||
existing._value = try String.init(page.arena, value, .{});
|
||||
existing._value = try String.init(page.arena, normalized_value, .{});
|
||||
existing._important = important;
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
const prop = try page._factory.create(Property{
|
||||
._node = .{},
|
||||
._name = try String.init(page.arena, normalized, .{}),
|
||||
._value = try String.init(page.arena, value, .{}),
|
||||
._value = try String.init(page.arena, normalized_value, .{}),
|
||||
._important = important,
|
||||
});
|
||||
self._properties.append(&prop._node);
|
||||
@@ -227,6 +232,395 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 {
|
||||
return std.ascii.lowerString(buf, name);
|
||||
}
|
||||
|
||||
// Normalize CSS property values for canonical serialization
|
||||
fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 {
|
||||
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
|
||||
if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) {
|
||||
return "0px";
|
||||
}
|
||||
|
||||
// "first baseline" serializes canonically as "baseline" (first is the default)
|
||||
if (std.ascii.startsWithIgnoreCase(value, "first baseline")) {
|
||||
if (value.len == 14) {
|
||||
// Exact match "first baseline"
|
||||
return "baseline";
|
||||
}
|
||||
if (value.len > 14 and value[14] == ' ') {
|
||||
// "first baseline X" -> "baseline X"
|
||||
return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] });
|
||||
}
|
||||
}
|
||||
|
||||
// For 2-value shorthand properties, collapse "X X" to "X"
|
||||
if (isTwoValueShorthand(property_name)) {
|
||||
if (collapseDuplicateValue(value)) |single| {
|
||||
return single;
|
||||
}
|
||||
}
|
||||
|
||||
// Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword
|
||||
if (std.mem.indexOf(u8, value, "anchor-size(") != null) {
|
||||
return try canonicalizeAnchorSize(arena, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword.
|
||||
// e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)"
|
||||
fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 {
|
||||
var buf = std.Io.Writer.Allocating.init(arena);
|
||||
var i: usize = 0;
|
||||
|
||||
while (i < value.len) {
|
||||
// Look for "anchor-size("
|
||||
if (std.mem.startsWith(u8, value[i..], "anchor-size(")) {
|
||||
try buf.writer.writeAll("anchor-size(");
|
||||
i += "anchor-size(".len;
|
||||
|
||||
// Parse and canonicalize the arguments
|
||||
i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer);
|
||||
} else {
|
||||
try buf.writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return buf.written();
|
||||
}
|
||||
|
||||
// Parse anchor-size arguments and write them in canonical order
|
||||
fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize {
|
||||
var i = start;
|
||||
var depth: usize = 1;
|
||||
|
||||
// Skip leading whitespace
|
||||
while (i < value.len and value[i] == ' ') : (i += 1) {}
|
||||
|
||||
// Collect tokens before the comma or close paren
|
||||
var first_token_start: ?usize = null;
|
||||
var first_token_end: usize = 0;
|
||||
var second_token_start: ?usize = null;
|
||||
var second_token_end: usize = 0;
|
||||
var comma_pos: ?usize = null;
|
||||
var token_count: usize = 0;
|
||||
|
||||
const args_start = i;
|
||||
var in_token = false;
|
||||
|
||||
// First pass: find the structure of arguments before comma/closing paren at depth 1
|
||||
while (i < value.len and depth > 0) {
|
||||
const c = value[i];
|
||||
|
||||
if (c == '(') {
|
||||
depth += 1;
|
||||
in_token = true;
|
||||
i += 1;
|
||||
} else if (c == ')') {
|
||||
depth -= 1;
|
||||
if (depth == 0) {
|
||||
if (in_token) {
|
||||
if (token_count == 0) {
|
||||
first_token_end = i;
|
||||
} else if (token_count == 1) {
|
||||
second_token_end = i;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
} else if (c == ',' and depth == 1) {
|
||||
if (in_token) {
|
||||
if (token_count == 0) {
|
||||
first_token_end = i;
|
||||
} else if (token_count == 1) {
|
||||
second_token_end = i;
|
||||
}
|
||||
}
|
||||
comma_pos = i;
|
||||
break;
|
||||
} else if (c == ' ') {
|
||||
if (in_token and depth == 1) {
|
||||
if (token_count == 0) {
|
||||
first_token_end = i;
|
||||
token_count = 1;
|
||||
} else if (token_count == 1 and second_token_start != null) {
|
||||
second_token_end = i;
|
||||
token_count = 2;
|
||||
}
|
||||
in_token = false;
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
if (!in_token and depth == 1) {
|
||||
if (token_count == 0) {
|
||||
first_token_start = i;
|
||||
} else if (token_count == 1) {
|
||||
second_token_start = i;
|
||||
}
|
||||
in_token = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle end of tokens
|
||||
if (in_token and token_count == 1 and second_token_start != null) {
|
||||
second_token_end = i;
|
||||
token_count = 2;
|
||||
} else if (in_token and token_count == 0) {
|
||||
first_token_end = i;
|
||||
token_count = 1;
|
||||
}
|
||||
|
||||
// Check if we have exactly two tokens that need reordering
|
||||
if (token_count == 2) {
|
||||
const first_start = first_token_start orelse args_start;
|
||||
const second_start = second_token_start orelse first_token_end;
|
||||
|
||||
const first_token = value[first_start..first_token_end];
|
||||
const second_token = value[second_start..second_token_end];
|
||||
|
||||
// If second token is a dashed ident and first is a size keyword, swap them
|
||||
if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) {
|
||||
try writer.writeAll(second_token);
|
||||
try writer.writeByte(' ');
|
||||
try writer.writeAll(first_token);
|
||||
} else {
|
||||
// Keep original order
|
||||
try writer.writeAll(first_token);
|
||||
try writer.writeByte(' ');
|
||||
try writer.writeAll(second_token);
|
||||
}
|
||||
} else if (first_token_start) |fts| {
|
||||
// Single token, just copy it
|
||||
try writer.writeAll(value[fts..first_token_end]);
|
||||
}
|
||||
|
||||
// Handle comma and fallback value (may contain nested anchor-size)
|
||||
if (comma_pos) |cp| {
|
||||
try writer.writeAll(", ");
|
||||
i = cp + 1;
|
||||
// Skip whitespace after comma
|
||||
while (i < value.len and value[i] == ' ') : (i += 1) {}
|
||||
|
||||
// Copy the fallback, recursively handling nested anchor-size
|
||||
while (i < value.len and depth > 0) {
|
||||
if (std.mem.startsWith(u8, value[i..], "anchor-size(")) {
|
||||
try writer.writeAll("anchor-size(");
|
||||
i += "anchor-size(".len;
|
||||
depth += 1;
|
||||
i = try canonicalizeAnchorSizeArgs(value, i, writer);
|
||||
depth -= 1;
|
||||
} else if (value[i] == '(') {
|
||||
depth += 1;
|
||||
try writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
} else if (value[i] == ')') {
|
||||
depth -= 1;
|
||||
if (depth == 0) break;
|
||||
try writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
try writer.writeByte(value[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write closing paren
|
||||
try writer.writeByte(')');
|
||||
|
||||
return i + 1; // Skip past the closing paren
|
||||
}
|
||||
|
||||
fn isAnchorSizeKeyword(token: []const u8) bool {
|
||||
const keywords = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "width", {} },
|
||||
.{ "height", {} },
|
||||
.{ "block", {} },
|
||||
.{ "inline", {} },
|
||||
.{ "self-block", {} },
|
||||
.{ "self-inline", {} },
|
||||
});
|
||||
return keywords.has(token);
|
||||
}
|
||||
|
||||
// Check if a value is "X X" (duplicate) and return just "X"
|
||||
fn collapseDuplicateValue(value: []const u8) ?[]const u8 {
|
||||
const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null;
|
||||
if (space_idx == 0 or space_idx >= value.len - 1) return null;
|
||||
|
||||
const first = value[0..space_idx];
|
||||
const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " ");
|
||||
|
||||
// Check if there's only one more value (no additional spaces)
|
||||
if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null;
|
||||
|
||||
if (std.mem.eql(u8, first, rest)) {
|
||||
return first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isTwoValueShorthand(name: []const u8) bool {
|
||||
const shorthands = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "place-content", {} },
|
||||
.{ "place-items", {} },
|
||||
.{ "place-self", {} },
|
||||
.{ "margin-block", {} },
|
||||
.{ "margin-inline", {} },
|
||||
.{ "padding-block", {} },
|
||||
.{ "padding-inline", {} },
|
||||
.{ "inset-block", {} },
|
||||
.{ "inset-inline", {} },
|
||||
.{ "border-block-style", {} },
|
||||
.{ "border-inline-style", {} },
|
||||
.{ "border-block-width", {} },
|
||||
.{ "border-inline-width", {} },
|
||||
.{ "border-block-color", {} },
|
||||
.{ "border-inline-color", {} },
|
||||
.{ "overflow", {} },
|
||||
.{ "overscroll-behavior", {} },
|
||||
.{ "gap", {} },
|
||||
.{ "grid-gap", {} },
|
||||
// Scroll
|
||||
.{ "scroll-padding-block", {} },
|
||||
.{ "scroll-padding-inline", {} },
|
||||
.{ "scroll-snap-align", {} },
|
||||
// Background/Mask
|
||||
.{ "background-size", {} },
|
||||
.{ "border-image-repeat", {} },
|
||||
.{ "mask-repeat", {} },
|
||||
.{ "mask-size", {} },
|
||||
});
|
||||
return shorthands.has(name);
|
||||
}
|
||||
|
||||
fn isLengthProperty(name: []const u8) bool {
|
||||
// Properties that accept <length> or <length-percentage> values
|
||||
const length_properties = std.StaticStringMap(void).initComptime(.{
|
||||
// Sizing
|
||||
.{ "width", {} },
|
||||
.{ "height", {} },
|
||||
.{ "min-width", {} },
|
||||
.{ "min-height", {} },
|
||||
.{ "max-width", {} },
|
||||
.{ "max-height", {} },
|
||||
// Margins
|
||||
.{ "margin", {} },
|
||||
.{ "margin-top", {} },
|
||||
.{ "margin-right", {} },
|
||||
.{ "margin-bottom", {} },
|
||||
.{ "margin-left", {} },
|
||||
.{ "margin-block", {} },
|
||||
.{ "margin-block-start", {} },
|
||||
.{ "margin-block-end", {} },
|
||||
.{ "margin-inline", {} },
|
||||
.{ "margin-inline-start", {} },
|
||||
.{ "margin-inline-end", {} },
|
||||
// Padding
|
||||
.{ "padding", {} },
|
||||
.{ "padding-top", {} },
|
||||
.{ "padding-right", {} },
|
||||
.{ "padding-bottom", {} },
|
||||
.{ "padding-left", {} },
|
||||
.{ "padding-block", {} },
|
||||
.{ "padding-block-start", {} },
|
||||
.{ "padding-block-end", {} },
|
||||
.{ "padding-inline", {} },
|
||||
.{ "padding-inline-start", {} },
|
||||
.{ "padding-inline-end", {} },
|
||||
// Positioning
|
||||
.{ "top", {} },
|
||||
.{ "right", {} },
|
||||
.{ "bottom", {} },
|
||||
.{ "left", {} },
|
||||
.{ "inset", {} },
|
||||
.{ "inset-block", {} },
|
||||
.{ "inset-block-start", {} },
|
||||
.{ "inset-block-end", {} },
|
||||
.{ "inset-inline", {} },
|
||||
.{ "inset-inline-start", {} },
|
||||
.{ "inset-inline-end", {} },
|
||||
// Border
|
||||
.{ "border-width", {} },
|
||||
.{ "border-top-width", {} },
|
||||
.{ "border-right-width", {} },
|
||||
.{ "border-bottom-width", {} },
|
||||
.{ "border-left-width", {} },
|
||||
.{ "border-block-width", {} },
|
||||
.{ "border-block-start-width", {} },
|
||||
.{ "border-block-end-width", {} },
|
||||
.{ "border-inline-width", {} },
|
||||
.{ "border-inline-start-width", {} },
|
||||
.{ "border-inline-end-width", {} },
|
||||
.{ "border-radius", {} },
|
||||
.{ "border-top-left-radius", {} },
|
||||
.{ "border-top-right-radius", {} },
|
||||
.{ "border-bottom-left-radius", {} },
|
||||
.{ "border-bottom-right-radius", {} },
|
||||
// Text
|
||||
.{ "font-size", {} },
|
||||
.{ "letter-spacing", {} },
|
||||
.{ "word-spacing", {} },
|
||||
.{ "text-indent", {} },
|
||||
// Flexbox/Grid
|
||||
.{ "gap", {} },
|
||||
.{ "row-gap", {} },
|
||||
.{ "column-gap", {} },
|
||||
.{ "flex-basis", {} },
|
||||
// Legacy grid aliases
|
||||
.{ "grid-column-gap", {} },
|
||||
.{ "grid-row-gap", {} },
|
||||
// Outline
|
||||
.{ "outline", {} },
|
||||
.{ "outline-width", {} },
|
||||
.{ "outline-offset", {} },
|
||||
// Multi-column
|
||||
.{ "column-rule-width", {} },
|
||||
.{ "column-width", {} },
|
||||
// Scroll
|
||||
.{ "scroll-margin", {} },
|
||||
.{ "scroll-margin-top", {} },
|
||||
.{ "scroll-margin-right", {} },
|
||||
.{ "scroll-margin-bottom", {} },
|
||||
.{ "scroll-margin-left", {} },
|
||||
.{ "scroll-padding", {} },
|
||||
.{ "scroll-padding-top", {} },
|
||||
.{ "scroll-padding-right", {} },
|
||||
.{ "scroll-padding-bottom", {} },
|
||||
.{ "scroll-padding-left", {} },
|
||||
// Shapes
|
||||
.{ "shape-margin", {} },
|
||||
// Motion path
|
||||
.{ "offset-distance", {} },
|
||||
// Transforms
|
||||
.{ "translate", {} },
|
||||
// Animations
|
||||
.{ "animation-range-end", {} },
|
||||
.{ "animation-range-start", {} },
|
||||
// Other
|
||||
.{ "border-spacing", {} },
|
||||
.{ "text-shadow", {} },
|
||||
.{ "box-shadow", {} },
|
||||
.{ "baseline-shift", {} },
|
||||
.{ "vertical-align", {} },
|
||||
.{ "text-decoration-inset", {} },
|
||||
.{ "block-step-size", {} },
|
||||
// Grid lanes
|
||||
.{ "flow-tolerance", {} },
|
||||
.{ "column-rule-edge-inset", {} },
|
||||
.{ "column-rule-interior-inset", {} },
|
||||
.{ "row-rule-edge-inset", {} },
|
||||
.{ "row-rule-interior-inset", {} },
|
||||
.{ "rule-edge-inset", {} },
|
||||
.{ "rule-interior-inset", {} },
|
||||
});
|
||||
|
||||
return length_properties.has(name);
|
||||
}
|
||||
|
||||
fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 {
|
||||
if (std.mem.eql(u8, normalized_name, "visibility")) {
|
||||
return "visible";
|
||||
@@ -255,7 +649,7 @@ fn getDefaultDisplay(element: *const Element) []const u8 {
|
||||
.html => |html| {
|
||||
return switch (html._type) {
|
||||
.anchor, .br, .span, .label, .time, .font, .mod, .quote => "inline",
|
||||
.body, .div, .dl, .p, .heading, .form, .button, .canvas, .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, .legend, .map, .meter, .object, .optgroup, .output, .param, .picture, .pre, .progress, .source, .table, .table_caption, .table_cell, .table_col, .table_row, .table_section, .track => "block",
|
||||
.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, .legend, .map, .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: {
|
||||
const tag = element.getTagNameLower();
|
||||
if (isInlineTag(tag)) break :blk "inline";
|
||||
@@ -343,3 +737,55 @@ pub const JsApi = struct {
|
||||
pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{});
|
||||
pub const cssFloat = bridge.accessor(CSSStyleDeclaration.getFloat, CSSStyleDeclaration.setFloat, .{});
|
||||
};
|
||||
|
||||
const testing = @import("std").testing;
|
||||
|
||||
test "normalizePropertyValue: unitless zero to 0px" {
|
||||
const cases = .{
|
||||
.{ "width", "0", "0px" },
|
||||
.{ "height", "0", "0px" },
|
||||
.{ "scroll-margin-top", "0", "0px" },
|
||||
.{ "scroll-padding-bottom", "0", "0px" },
|
||||
.{ "column-width", "0", "0px" },
|
||||
.{ "column-rule-width", "0", "0px" },
|
||||
.{ "outline", "0", "0px" },
|
||||
.{ "shape-margin", "0", "0px" },
|
||||
.{ "offset-distance", "0", "0px" },
|
||||
.{ "translate", "0", "0px" },
|
||||
.{ "grid-column-gap", "0", "0px" },
|
||||
.{ "grid-row-gap", "0", "0px" },
|
||||
// Non-length properties should NOT normalize
|
||||
.{ "opacity", "0", "0" },
|
||||
.{ "z-index", "0", "0" },
|
||||
};
|
||||
inline for (cases) |case| {
|
||||
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]);
|
||||
try testing.expectEqualStrings(case[2], result);
|
||||
}
|
||||
}
|
||||
|
||||
test "normalizePropertyValue: first baseline to baseline" {
|
||||
const result = try normalizePropertyValue(testing.allocator, "align-items", "first baseline");
|
||||
try testing.expectEqualStrings("baseline", result);
|
||||
|
||||
const result2 = try normalizePropertyValue(testing.allocator, "align-self", "last baseline");
|
||||
try testing.expectEqualStrings("last baseline", result2);
|
||||
}
|
||||
|
||||
test "normalizePropertyValue: collapse duplicate two-value shorthands" {
|
||||
const cases = .{
|
||||
.{ "overflow", "hidden hidden", "hidden" },
|
||||
.{ "gap", "10px 10px", "10px" },
|
||||
.{ "scroll-snap-align", "start start", "start" },
|
||||
.{ "scroll-padding-block", "5px 5px", "5px" },
|
||||
.{ "background-size", "auto auto", "auto" },
|
||||
.{ "overscroll-behavior", "auto auto", "auto" },
|
||||
// Different values should NOT collapse
|
||||
.{ "overflow", "hidden scroll", "hidden scroll" },
|
||||
.{ "gap", "10px 20px", "10px 20px" },
|
||||
};
|
||||
inline for (cases) |case| {
|
||||
const result = try normalizePropertyValue(testing.allocator, case[0], case[1]);
|
||||
try testing.expectEqualStrings(case[2], result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con
|
||||
}
|
||||
|
||||
fn isKnownCSSProperty(dash_case: []const u8) bool {
|
||||
// List of common/known CSS properties
|
||||
// In a full implementation, this would include all standard CSS properties
|
||||
const known_properties = std.StaticStringMap(void).initComptime(.{
|
||||
// Colors & backgrounds
|
||||
.{ "color", {} },
|
||||
.{ "background", {} },
|
||||
.{ "background-color", {} },
|
||||
.{ "background-image", {} },
|
||||
.{ "background-position", {} },
|
||||
.{ "background-repeat", {} },
|
||||
.{ "background-size", {} },
|
||||
.{ "background-attachment", {} },
|
||||
.{ "background-clip", {} },
|
||||
.{ "background-origin", {} },
|
||||
// Typography
|
||||
.{ "font", {} },
|
||||
.{ "font-family", {} },
|
||||
.{ "font-size", {} },
|
||||
.{ "font-style", {} },
|
||||
.{ "font-weight", {} },
|
||||
.{ "font-variant", {} },
|
||||
.{ "line-height", {} },
|
||||
.{ "letter-spacing", {} },
|
||||
.{ "word-spacing", {} },
|
||||
.{ "text-align", {} },
|
||||
.{ "text-decoration", {} },
|
||||
.{ "text-indent", {} },
|
||||
.{ "text-transform", {} },
|
||||
.{ "white-space", {} },
|
||||
.{ "word-break", {} },
|
||||
.{ "word-wrap", {} },
|
||||
.{ "overflow-wrap", {} },
|
||||
// Box model
|
||||
.{ "margin", {} },
|
||||
.{ "margin-top", {} },
|
||||
.{ "margin-right", {} },
|
||||
.{ "margin-bottom", {} },
|
||||
.{ "margin-left", {} },
|
||||
.{ "margin-right", {} },
|
||||
.{ "margin-block", {} },
|
||||
.{ "margin-block-start", {} },
|
||||
.{ "margin-block-end", {} },
|
||||
.{ "margin-inline", {} },
|
||||
.{ "margin-inline-start", {} },
|
||||
.{ "margin-inline-end", {} },
|
||||
.{ "padding", {} },
|
||||
.{ "padding-top", {} },
|
||||
.{ "padding-right", {} },
|
||||
.{ "padding-bottom", {} },
|
||||
.{ "padding-left", {} },
|
||||
.{ "padding-right", {} },
|
||||
.{ "padding-block", {} },
|
||||
.{ "padding-block-start", {} },
|
||||
.{ "padding-block-end", {} },
|
||||
.{ "padding-inline", {} },
|
||||
.{ "padding-inline-start", {} },
|
||||
.{ "padding-inline-end", {} },
|
||||
// Border
|
||||
.{ "border", {} },
|
||||
.{ "border-width", {} },
|
||||
.{ "border-style", {} },
|
||||
.{ "border-color", {} },
|
||||
.{ "border-top", {} },
|
||||
.{ "border-top-width", {} },
|
||||
.{ "border-top-style", {} },
|
||||
.{ "border-top-color", {} },
|
||||
.{ "border-right", {} },
|
||||
.{ "border-right-width", {} },
|
||||
.{ "border-right-style", {} },
|
||||
.{ "border-right-color", {} },
|
||||
.{ "border-bottom", {} },
|
||||
.{ "border-bottom-width", {} },
|
||||
.{ "border-bottom-style", {} },
|
||||
.{ "border-bottom-color", {} },
|
||||
.{ "border-left", {} },
|
||||
.{ "border-left-width", {} },
|
||||
.{ "border-left-style", {} },
|
||||
.{ "border-left-color", {} },
|
||||
.{ "border-radius", {} },
|
||||
.{ "border-top-left-radius", {} },
|
||||
.{ "border-top-right-radius", {} },
|
||||
.{ "border-bottom-left-radius", {} },
|
||||
.{ "border-bottom-right-radius", {} },
|
||||
.{ "float", {} },
|
||||
.{ "z-index", {} },
|
||||
.{ "border-collapse", {} },
|
||||
.{ "border-spacing", {} },
|
||||
// Sizing
|
||||
.{ "width", {} },
|
||||
.{ "height", {} },
|
||||
.{ "min-width", {} },
|
||||
.{ "min-height", {} },
|
||||
.{ "max-width", {} },
|
||||
.{ "max-height", {} },
|
||||
.{ "box-sizing", {} },
|
||||
// Positioning
|
||||
.{ "position", {} },
|
||||
.{ "top", {} },
|
||||
.{ "right", {} },
|
||||
.{ "bottom", {} },
|
||||
.{ "left", {} },
|
||||
.{ "inset", {} },
|
||||
.{ "inset-block", {} },
|
||||
.{ "inset-block-start", {} },
|
||||
.{ "inset-block-end", {} },
|
||||
.{ "inset-inline", {} },
|
||||
.{ "inset-inline-start", {} },
|
||||
.{ "inset-inline-end", {} },
|
||||
.{ "z-index", {} },
|
||||
.{ "float", {} },
|
||||
.{ "clear", {} },
|
||||
// Display & visibility
|
||||
.{ "display", {} },
|
||||
.{ "visibility", {} },
|
||||
.{ "opacity", {} },
|
||||
.{ "filter", {} },
|
||||
.{ "overflow", {} },
|
||||
.{ "overflow-x", {} },
|
||||
.{ "overflow-y", {} },
|
||||
.{ "clip", {} },
|
||||
.{ "clip-path", {} },
|
||||
// Flexbox
|
||||
.{ "flex", {} },
|
||||
.{ "flex-direction", {} },
|
||||
.{ "flex-wrap", {} },
|
||||
.{ "flex-flow", {} },
|
||||
.{ "flex-grow", {} },
|
||||
.{ "flex-shrink", {} },
|
||||
.{ "flex-basis", {} },
|
||||
.{ "order", {} },
|
||||
// Grid
|
||||
.{ "grid", {} },
|
||||
.{ "grid-template", {} },
|
||||
.{ "grid-template-columns", {} },
|
||||
.{ "grid-template-rows", {} },
|
||||
.{ "grid-template-areas", {} },
|
||||
.{ "grid-auto-columns", {} },
|
||||
.{ "grid-auto-rows", {} },
|
||||
.{ "grid-auto-flow", {} },
|
||||
.{ "grid-column", {} },
|
||||
.{ "grid-column-start", {} },
|
||||
.{ "grid-column-end", {} },
|
||||
.{ "grid-row", {} },
|
||||
.{ "grid-row-start", {} },
|
||||
.{ "grid-row-end", {} },
|
||||
.{ "grid-area", {} },
|
||||
.{ "gap", {} },
|
||||
.{ "row-gap", {} },
|
||||
.{ "column-gap", {} },
|
||||
// Alignment (flexbox & grid)
|
||||
.{ "align-content", {} },
|
||||
.{ "align-items", {} },
|
||||
.{ "align-self", {} },
|
||||
.{ "justify-content", {} },
|
||||
.{ "justify-items", {} },
|
||||
.{ "justify-self", {} },
|
||||
.{ "place-content", {} },
|
||||
.{ "place-items", {} },
|
||||
.{ "place-self", {} },
|
||||
// Transforms & animations
|
||||
.{ "transform", {} },
|
||||
.{ "transform-origin", {} },
|
||||
.{ "transform-style", {} },
|
||||
.{ "perspective", {} },
|
||||
.{ "perspective-origin", {} },
|
||||
.{ "transition", {} },
|
||||
.{ "position", {} },
|
||||
.{ "top", {} },
|
||||
.{ "bottom", {} },
|
||||
.{ "left", {} },
|
||||
.{ "right", {} },
|
||||
.{ "transition-property", {} },
|
||||
.{ "transition-duration", {} },
|
||||
.{ "transition-timing-function", {} },
|
||||
.{ "transition-delay", {} },
|
||||
.{ "animation", {} },
|
||||
.{ "animation-name", {} },
|
||||
.{ "animation-duration", {} },
|
||||
.{ "animation-timing-function", {} },
|
||||
.{ "animation-delay", {} },
|
||||
.{ "animation-iteration-count", {} },
|
||||
.{ "animation-direction", {} },
|
||||
.{ "animation-fill-mode", {} },
|
||||
.{ "animation-play-state", {} },
|
||||
// Filters & effects
|
||||
.{ "filter", {} },
|
||||
.{ "backdrop-filter", {} },
|
||||
.{ "box-shadow", {} },
|
||||
.{ "text-shadow", {} },
|
||||
// Outline
|
||||
.{ "outline", {} },
|
||||
.{ "outline-width", {} },
|
||||
.{ "outline-style", {} },
|
||||
.{ "outline-color", {} },
|
||||
.{ "outline-offset", {} },
|
||||
// Lists
|
||||
.{ "list-style", {} },
|
||||
.{ "list-style-type", {} },
|
||||
.{ "list-style-position", {} },
|
||||
.{ "list-style-image", {} },
|
||||
// Tables
|
||||
.{ "table-layout", {} },
|
||||
.{ "caption-side", {} },
|
||||
.{ "empty-cells", {} },
|
||||
// Misc
|
||||
.{ "cursor", {} },
|
||||
.{ "pointer-events", {} },
|
||||
.{ "user-select", {} },
|
||||
.{ "resize", {} },
|
||||
.{ "object-fit", {} },
|
||||
.{ "object-position", {} },
|
||||
.{ "vertical-align", {} },
|
||||
.{ "content", {} },
|
||||
.{ "quotes", {} },
|
||||
.{ "counter-reset", {} },
|
||||
.{ "counter-increment", {} },
|
||||
// Scrolling
|
||||
.{ "scroll-behavior", {} },
|
||||
.{ "scroll-margin", {} },
|
||||
.{ "scroll-padding", {} },
|
||||
.{ "overscroll-behavior", {} },
|
||||
.{ "overscroll-behavior-x", {} },
|
||||
.{ "overscroll-behavior-y", {} },
|
||||
// Containment
|
||||
.{ "contain", {} },
|
||||
.{ "container", {} },
|
||||
.{ "container-type", {} },
|
||||
.{ "container-name", {} },
|
||||
// Aspect ratio
|
||||
.{ "aspect-ratio", {} },
|
||||
});
|
||||
|
||||
return known_properties.has(dash_case);
|
||||
|
||||
91
src/browser/webapi/css/FontFace.zig
Normal file
91
src/browser/webapi/css/FontFace.zig
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const FontFace = @This();
|
||||
|
||||
_arena: Allocator,
|
||||
_family: []const u8,
|
||||
|
||||
pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace {
|
||||
_ = source;
|
||||
|
||||
const arena = try page.getArena(.{ .debug = "FontFace" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const self = try arena.create(FontFace);
|
||||
self.* = .{
|
||||
._arena = arena,
|
||||
._family = try arena.dupe(u8, family),
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *FontFace, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn getFamily(self: *const FontFace) []const u8 {
|
||||
return self._family;
|
||||
}
|
||||
|
||||
// load() - resolves immediately; headless browser has no real font loading.
|
||||
pub fn load(_: *FontFace, page: *Page) !js.Promise {
|
||||
return page.js.local.?.resolvePromise({});
|
||||
}
|
||||
|
||||
// loaded - returns an already-resolved Promise.
|
||||
pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise {
|
||||
return page.js.local.?.resolvePromise({});
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(FontFace);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "FontFace";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(FontFace.deinit);
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(FontFace.init, .{});
|
||||
pub const family = bridge.accessor(FontFace.getFamily, null, .{});
|
||||
pub const status = bridge.property("loaded", .{ .template = false, .readonly = true });
|
||||
pub const style = bridge.property("normal", .{ .template = false, .readonly = true });
|
||||
pub const weight = bridge.property("normal", .{ .template = false, .readonly = true });
|
||||
pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true });
|
||||
pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true });
|
||||
pub const variant = bridge.property("normal", .{ .template = false, .readonly = true });
|
||||
pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true });
|
||||
pub const display = bridge.property("auto", .{ .template = false, .readonly = true });
|
||||
pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{});
|
||||
pub const load = bridge.function(FontFace.load, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "WebApi: FontFace" {
|
||||
try testing.htmlRunner("css/font_face.html", .{});
|
||||
}
|
||||
@@ -1,14 +1,46 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const FontFace = @import("FontFace.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const FontFaceSet = @This();
|
||||
|
||||
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
|
||||
_pad: bool = false,
|
||||
_arena: Allocator,
|
||||
|
||||
pub fn init(page: *Page) !*FontFaceSet {
|
||||
return page._factory.create(FontFaceSet{});
|
||||
const arena = try page.getArena(.{ .debug = "FontFaceSet" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const self = try arena.create(FontFaceSet);
|
||||
self.* = .{
|
||||
._arena = arena,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *FontFaceSet, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
// FontFaceSet.ready - returns an already-resolved Promise.
|
||||
@@ -29,6 +61,11 @@ pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise {
|
||||
return page.js.local.?.resolvePromise({});
|
||||
}
|
||||
|
||||
// add(fontFace) - no-op; headless browser does not track loaded fonts.
|
||||
pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(FontFaceSet);
|
||||
|
||||
@@ -36,6 +73,8 @@ pub const JsApi = struct {
|
||||
pub const name = "FontFaceSet";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(FontFaceSet.deinit);
|
||||
};
|
||||
|
||||
pub const size = bridge.property(0, .{ .template = false, .readonly = true });
|
||||
@@ -43,6 +82,7 @@ pub const JsApi = struct {
|
||||
pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{});
|
||||
pub const check = bridge.function(FontFaceSet.check, .{});
|
||||
pub const load = bridge.function(FontFaceSet.load, .{});
|
||||
pub const add = bridge.function(FontFaceSet.add, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user