mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 17:25:58 -04:00
Merge branch 'main' into run-command
# Conflicts: # src/Config.zig # src/help.zon
This commit is contained in:
24
README.md
24
README.md
@@ -48,7 +48,7 @@ brew install lightpanda-io/browser/lightpanda
|
||||
|
||||
Latest nightly from Arch Linux User Repository:
|
||||
```console
|
||||
yay -S lightpanda-nightly-bi
|
||||
yay -S lightpanda-nightly-bin
|
||||
```
|
||||
|
||||
**Download from the nightly builds**
|
||||
@@ -200,6 +200,28 @@ Add to your MCP configuration:
|
||||
}
|
||||
```
|
||||
|
||||
#### HTTP transport and independent sessions
|
||||
|
||||
For serving several agents from one process, start the MCP server over HTTP
|
||||
instead of stdio by giving it a port (add `--host x.x.x.x` to specify the
|
||||
interface to listen on):
|
||||
|
||||
```bash
|
||||
lightpanda mcp --port 9223
|
||||
```
|
||||
|
||||
Clients POST JSON-RPC to `http://host:9223/mcp`. Each connection is routed to
|
||||
its own **browsing session** — its own page, cookies and memory — so agents no
|
||||
longer clobber each other's page:
|
||||
|
||||
- A client that `initialize`s without an `Mcp-Session-Id` header is assigned a
|
||||
fresh session; the id comes back in the response's `Mcp-Session-Id` header.
|
||||
Send it on subsequent requests to stay on that session (**isolation**).
|
||||
- Two agents that send the **same** `Mcp-Session-Id` share one browsing context
|
||||
(**sharing** — e.g. a workflow where several agents work the same page).
|
||||
- The `session_new`, `session_list` and `session_close` tools manage sessions
|
||||
explicitly. Sending `DELETE /mcp` with an `Mcp-Session-Id` closes that session.
|
||||
|
||||
[Read full documentation](https://lightpanda.io/docs/open-source/guides/mcp-server)
|
||||
|
||||
A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpanda-io/agent-skill).
|
||||
|
||||
32
build.zig
32
build.zig
@@ -176,6 +176,38 @@ pub fn build(b: *Build) !void {
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
}
|
||||
|
||||
{
|
||||
// skills generator
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "lightpanda-skills",
|
||||
.use_llvm = true,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main_skills.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "lightpanda", .module = lightpanda_module },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const exe_check = b.addLibrary(.{
|
||||
.name = "skills_check",
|
||||
.root_module = exe.root_module,
|
||||
});
|
||||
check.dependOn(&exe_check.step);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
const out_dir = run_cmd.addOutputDirectoryArg("skills");
|
||||
const install = b.addInstallDirectory(.{
|
||||
.source_dir = out_dir,
|
||||
.install_dir = .prefix,
|
||||
.install_subdir = "skills",
|
||||
});
|
||||
const skills_step = b.step("skills", "Generate LLM skill docs (zig-out/skills/<name>/SKILL.md)");
|
||||
skills_step.dependOn(&install.step);
|
||||
}
|
||||
|
||||
{
|
||||
// test
|
||||
const tests = b.addTest(.{
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
.hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr",
|
||||
},
|
||||
.zenai = .{
|
||||
.url = "git+https://github.com/lightpanda-io/zenai.git#e58635146733157eb4936388b21a071fcabfa687",
|
||||
.hash = "zenai-0.0.0-iOY_VIf0BADH7PvSOlnVf564krXnAlns_1IIhh9ZDdwz",
|
||||
.url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7",
|
||||
.hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu",
|
||||
},
|
||||
.isocline = .{
|
||||
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",
|
||||
|
||||
@@ -111,25 +111,27 @@ pub fn deinit(self: *ArenaPool) void {
|
||||
// - Pass a BucketSize (.tiny, .small, .medium, .large) for explicit bucket selection
|
||||
// - Pass a usize for automatic bucket selection based on expected size
|
||||
pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Allocator {
|
||||
const bucket = blk: {
|
||||
const bucket_size: BucketSize = blk: {
|
||||
const T = @TypeOf(size_or_bucket);
|
||||
if (T == BucketSize or T == @TypeOf(.enum_literal)) {
|
||||
break :blk switch (@as(BucketSize, size_or_bucket)) {
|
||||
.tiny => &self.tiny,
|
||||
.small => &self.small,
|
||||
.medium => &self.medium,
|
||||
.large => &self.large,
|
||||
};
|
||||
break :blk @as(BucketSize, size_or_bucket);
|
||||
}
|
||||
if (T == usize or T == comptime_int) {
|
||||
if (size_or_bucket <= self.tiny.retain_bytes) break :blk &self.tiny;
|
||||
if (size_or_bucket <= self.small.retain_bytes) break :blk &self.small;
|
||||
if (size_or_bucket <= self.medium.retain_bytes) break :blk &self.medium;
|
||||
break :blk &self.large;
|
||||
if (size_or_bucket <= self.tiny.retain_bytes) break :blk .tiny;
|
||||
if (size_or_bucket <= self.small.retain_bytes) break :blk .small;
|
||||
if (size_or_bucket <= self.medium.retain_bytes) break :blk .medium;
|
||||
break :blk .large;
|
||||
}
|
||||
@compileError("acquire expects BucketSize or usize, got " ++ @typeName(T));
|
||||
};
|
||||
|
||||
const bucket = switch (bucket_size) {
|
||||
.tiny => &self.tiny,
|
||||
.small => &self.small,
|
||||
.medium => &self.medium,
|
||||
.large => &self.large,
|
||||
};
|
||||
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
@@ -144,9 +146,12 @@ pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Al
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
}
|
||||
lp.metrics.arena_hit.incr(bucket_size);
|
||||
return entry.arena.allocator();
|
||||
}
|
||||
|
||||
lp.metrics.arena_miss.incr(bucket_size);
|
||||
|
||||
const entry = try self.entry_pool.create();
|
||||
entry.* = .{
|
||||
.next = null,
|
||||
|
||||
@@ -203,6 +203,7 @@ const Commands = cli.Builder(.{
|
||||
.{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 },
|
||||
// Don't widen this without growing the reader buffer in the HTTP path.
|
||||
.{ .name = "cdp_max_http_message_size", .type = u14, .default = 4096 },
|
||||
.{ .name = "disable_metrics", .type = bool },
|
||||
},
|
||||
.shared_options = CommonOptions,
|
||||
},
|
||||
@@ -235,12 +236,15 @@ const Commands = cli.Builder(.{
|
||||
},
|
||||
.{ .name = "terminate_ms", .type = ?u32 },
|
||||
.{ .name = "json", .type = bool },
|
||||
.{ .name = "metrics", .type = bool },
|
||||
},
|
||||
.shared_options = CommonOptions,
|
||||
},
|
||||
.{
|
||||
.name = "mcp",
|
||||
.options = .{
|
||||
.{ .name = "port", .type = ?u16 },
|
||||
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
|
||||
.{ .name = "cdp_port", .type = ?u16 },
|
||||
},
|
||||
.shared_options = CommonOptions,
|
||||
@@ -595,6 +599,20 @@ pub fn cdpMaxMessageSize(self: *const Config) u32 {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn metricsEndpointEnabled(self: *const Config) bool {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| !opts.disable_metrics,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn dumpMetricsOnExit(self: *const Config) bool {
|
||||
return switch (self.mode) {
|
||||
.fetch => |opts| opts.metrics,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.cdp_max_http_message_size,
|
||||
@@ -707,7 +725,7 @@ pub const HttpHeaders = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) void {
|
||||
pub fn printUsageAndExit(self: *const Config, allocator: Allocator, help_for: RunMode, success: bool) !void {
|
||||
const exec_name = self.exec_name;
|
||||
const Help = @import("help.zon");
|
||||
const is_debug = builtin.mode == .Debug;
|
||||
@@ -715,36 +733,86 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool)
|
||||
const pretty_or_logfmt = if (comptime is_debug) "pretty" else "logfmt";
|
||||
const comptimePrint = std.fmt.comptimePrint;
|
||||
|
||||
switch (help_for) {
|
||||
const text = switch (help_for) {
|
||||
// Requested help for everything.
|
||||
.help => {
|
||||
.help => text: {
|
||||
const template = comptimePrint(
|
||||
\\{s}
|
||||
\\
|
||||
, .{Help.general});
|
||||
std.debug.print(template, .{exec_name});
|
||||
break :text try std.fmt.allocPrint(allocator, template, .{exec_name});
|
||||
},
|
||||
inline .fetch, .serve, .mcp, .agent, .run => |tag| {
|
||||
inline .fetch, .serve, .mcp, .agent, .run => |tag| text: {
|
||||
const template = comptimePrint(
|
||||
\\{s}
|
||||
\\
|
||||
\\{s}
|
||||
\\
|
||||
, .{ @field(Help, @tagName(tag)), Help.common_options });
|
||||
std.debug.print(template, .{ exec_name, info_or_warn, pretty_or_logfmt });
|
||||
break :text try std.fmt.allocPrint(allocator, template, .{ exec_name, info_or_warn, pretty_or_logfmt });
|
||||
},
|
||||
.version => {
|
||||
.version => text: {
|
||||
const template = Help.version ++ "\n";
|
||||
std.debug.print(template, .{exec_name});
|
||||
break :text try std.fmt.allocPrint(allocator, template, .{exec_name});
|
||||
},
|
||||
}
|
||||
};
|
||||
defer allocator.free(text);
|
||||
|
||||
if (success) {
|
||||
printPaged(allocator, text);
|
||||
return std.process.cleanExit();
|
||||
}
|
||||
var stderr = std.fs.File.stderr().writer(&.{});
|
||||
stderr.interface.writeAll(text) catch {};
|
||||
std.process.exit(1);
|
||||
}
|
||||
|
||||
fn printPlain(text: []const u8) void {
|
||||
var stdout = std.fs.File.stdout().writer(&.{});
|
||||
stdout.interface.writeAll(text) catch {};
|
||||
}
|
||||
|
||||
/// Pages explicitly requested help through $PAGER (fallback: less) when
|
||||
/// stdout is an interactive terminal; prints plainly otherwise.
|
||||
fn printPaged(allocator: Allocator, text: []const u8) void {
|
||||
if (!std.posix.isatty(std.posix.STDOUT_FILENO)) {
|
||||
return printPlain(text);
|
||||
}
|
||||
const term = std.posix.getenv("TERM") orelse "";
|
||||
if (term.len == 0 or std.mem.eql(u8, term, "dumb")) {
|
||||
return printPlain(text);
|
||||
}
|
||||
|
||||
const pager = std.posix.getenv("PAGER") orelse "";
|
||||
const argv: []const []const u8 = if (pager.len > 0)
|
||||
&.{ "/bin/sh", "-c", pager }
|
||||
else
|
||||
&.{ "less", "-FIRX" };
|
||||
|
||||
var child = std.process.Child.init(argv, allocator);
|
||||
child.stdin_behavior = .Pipe;
|
||||
child.stdout_behavior = .Inherit;
|
||||
child.stderr_behavior = .Inherit;
|
||||
child.spawn() catch return printPlain(text);
|
||||
|
||||
if (child.stdin) |stdin| {
|
||||
var writer = stdin.writer(&.{});
|
||||
// A write error here is the pager exiting early (user quit, or the
|
||||
// command failed) — wait() below decides which.
|
||||
writer.interface.writeAll(text) catch {};
|
||||
stdin.close();
|
||||
child.stdin = null;
|
||||
}
|
||||
|
||||
const term_result = child.wait() catch return printPlain(text);
|
||||
const clean_exit = term_result == .Exited and term_result.Exited == 0;
|
||||
// Quitting the pager early is still exit 0; a non-zero exit means the
|
||||
// pager failed (e.g. $PAGER not found) and the help was never shown.
|
||||
if (!clean_exit) {
|
||||
printPlain(text);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parseArgs(allocator: Allocator) !Config {
|
||||
const exec_name, var command = try Commands.parse(allocator);
|
||||
if (command == .serve and command.serve.timeout != null) {
|
||||
|
||||
299
src/Metrics.zig
Normal file
299
src/Metrics.zig
Normal file
@@ -0,0 +1,299 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const Metrics = @This();
|
||||
|
||||
cdp_connections: Counter = .{},
|
||||
cdp_connection_limit: Counter = .{},
|
||||
cdp_active_connections: Gauge = .{},
|
||||
cdp_commands: Counter = .{},
|
||||
cdp_unknown_commands: Counter = .{},
|
||||
js_heap_limits: Counter = .{},
|
||||
script_errors: Counter = .{},
|
||||
arena_hit: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{},
|
||||
arena_miss: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{},
|
||||
navigate: CounterEnum("type", @import("telemetry/telemetry.zig").Event.Navigate.Context) = .{},
|
||||
js_heap_size_bytes: Histogram(&.{
|
||||
4 * 1024 * 1024,
|
||||
8 * 1024 * 1024,
|
||||
16 * 1024 * 1024,
|
||||
32 * 1024 * 1024,
|
||||
64 * 1024 * 1024,
|
||||
128 * 1024 * 1024,
|
||||
256 * 1024 * 1024,
|
||||
512 * 1024 * 1024,
|
||||
}) = .{},
|
||||
http_requests: CounterEnum("mode", enum { sync, async }) = .{},
|
||||
http_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
|
||||
http_error: CounterEnum("reason", @import("network/http.zig").ErrorReason) = .{},
|
||||
http_cache: CounterEnum("result", enum { hit, miss, revalidated }) = .{},
|
||||
http_redirects: Counter = .{},
|
||||
http_duration_ms: Histogram(&.{
|
||||
5,
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100,
|
||||
250,
|
||||
500,
|
||||
1000,
|
||||
2500,
|
||||
5000,
|
||||
10000,
|
||||
}) = .{},
|
||||
http_response_size_bytes: Histogram(&.{
|
||||
32 * 1024,
|
||||
64 * 1024,
|
||||
128 * 1024,
|
||||
256 * 1024,
|
||||
512 * 1024,
|
||||
1024 * 1024,
|
||||
2 * 1024 * 1024,
|
||||
4 * 1024 * 1024,
|
||||
}) = .{},
|
||||
robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
|
||||
robots_access: CounterEnum("result", enum { allow, deny }) = .{},
|
||||
|
||||
// Emitted as each metric's "# HELP" line. A field without an entry is a
|
||||
// compile error.
|
||||
const help = .{
|
||||
.cdp_connections = "CDP websocket connections accepted",
|
||||
.cdp_connection_limit = "Connections rejected because --cdp-max-connections was reached",
|
||||
.cdp_active_connections = "Currently connected CDP clients",
|
||||
.cdp_commands = "CDP commands dispatched",
|
||||
.cdp_unknown_commands = "CDP commands rejected for an unknown domain or method",
|
||||
.js_heap_limits = "Pages terminated for reaching the V8 heap limit",
|
||||
.script_errors = "Scripts that failed to evaluate, e.g. an uncaught top-level exception",
|
||||
.arena_hit = "Arena pool acquisitions served from the free list",
|
||||
.arena_miss = "Arena pool acquisitions that had to allocate a new arena",
|
||||
.navigate = "Navigations by initiating frame type",
|
||||
.js_heap_size_bytes = "V8 heap physical size, sampled when a page is closed",
|
||||
.http_requests = "HTTP requests submitted, by dispatch mode (excludes internal requests like robots.txt)",
|
||||
.http_status = "Final HTTP response status category (redirects counted once, at the final hop)",
|
||||
.http_error = "HTTP requests that failed before delivering a response, by cause",
|
||||
.http_cache = "HTTP cache lookups by outcome",
|
||||
.http_redirects = "HTTP redirect hops followed",
|
||||
.http_duration_ms = "HTTP request wall-clock duration in milliseconds",
|
||||
.http_response_size_bytes = "HTTP response body size in bytes",
|
||||
.robots_status = "robots.txt response status",
|
||||
.robots_access = "robots.txt result",
|
||||
};
|
||||
|
||||
pub fn write(self: *const Metrics, writer: *std.Io.Writer) void {
|
||||
self._write(writer) catch |err| {
|
||||
lp.log.err(.app, "metrics write", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn _write(self: *const Metrics, writer: *std.Io.Writer) !void {
|
||||
try writer.print(
|
||||
"# HELP build_info Lightpanda build information\n" ++
|
||||
"# TYPE build_info gauge\nbuild_info{{version=\"{s}\"}} 1\n",
|
||||
.{lp.build_config.version},
|
||||
);
|
||||
inline for (@typeInfo(Metrics).@"struct".fields) |f| {
|
||||
try @field(self, f.name).write(f.name, @field(help, f.name), writer);
|
||||
}
|
||||
}
|
||||
|
||||
const Counter = struct {
|
||||
count: usize = 0,
|
||||
|
||||
pub fn incr(self: *Counter) void {
|
||||
self.incrBy(1);
|
||||
}
|
||||
|
||||
pub fn incrBy(self: *Counter, c: usize) void {
|
||||
_ = @atomicRmw(usize, &self.count, .Add, c, .monotonic);
|
||||
}
|
||||
|
||||
fn write(self: *const Counter, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n");
|
||||
try writer.print(name ++ "_total {d}\n", .{self.get()});
|
||||
}
|
||||
|
||||
fn get(self: *const Counter) usize {
|
||||
return @atomicLoad(usize, &self.count, .monotonic);
|
||||
}
|
||||
};
|
||||
|
||||
const Gauge = struct {
|
||||
value: isize = 0,
|
||||
|
||||
pub fn incr(self: *Gauge) void {
|
||||
_ = @atomicRmw(isize, &self.value, .Add, 1, .monotonic);
|
||||
}
|
||||
|
||||
pub fn decr(self: *Gauge) void {
|
||||
_ = @atomicRmw(isize, &self.value, .Sub, 1, .monotonic);
|
||||
}
|
||||
|
||||
fn write(self: *const Gauge, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " gauge\n");
|
||||
try writer.print(name ++ " {d}\n", .{@atomicLoad(isize, &self.value, .monotonic)});
|
||||
}
|
||||
};
|
||||
|
||||
fn CounterEnum(comptime label: []const u8, comptime T: type) type {
|
||||
return struct {
|
||||
counts: std.enums.EnumArray(T, Counter) = .initFill(.{}),
|
||||
|
||||
pub const Tag = T;
|
||||
pub const label_name = label;
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn incr(self: *Self, tag: T) void {
|
||||
self.incrBy(tag, 1);
|
||||
}
|
||||
|
||||
pub fn incrBy(self: *Self, tag: T, c: usize) void {
|
||||
self.counts.getPtr(tag).incrBy(c);
|
||||
}
|
||||
|
||||
fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n");
|
||||
inline for (comptime std.enums.values(Tag)) |tag| {
|
||||
try writer.print(name ++ "_total{{" ++ label ++ "=\"" ++ @tagName(tag) ++ "\"}} {d}\n", .{self.counts.getPtrConst(tag).get()});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn Histogram(comptime upper_bounds: []const usize) type {
|
||||
comptime {
|
||||
std.debug.assert(upper_bounds.len > 0);
|
||||
for (upper_bounds[0 .. upper_bounds.len - 1], upper_bounds[1..]) |a, b| {
|
||||
std.debug.assert(a < b);
|
||||
}
|
||||
}
|
||||
|
||||
return struct {
|
||||
sum: usize = 0,
|
||||
count: usize = 0,
|
||||
buckets: [upper_bounds.len]usize = @splat(0),
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn observe(self: *Self, value: usize) void {
|
||||
_ = @atomicRmw(usize, &self.count, .Add, 1, .monotonic);
|
||||
_ = @atomicRmw(usize, &self.sum, .Add, value, .monotonic);
|
||||
inline for (upper_bounds, 0..) |upper, i| {
|
||||
if (value <= upper) {
|
||||
_ = @atomicRmw(usize, &self.buckets[i], .Add, 1, .monotonic);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// falls in the implicit +Inf bucket, which is derived from count
|
||||
}
|
||||
|
||||
fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
|
||||
try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " histogram\n");
|
||||
|
||||
// le buckets are cumulative
|
||||
var sum: usize = 0;
|
||||
inline for (upper_bounds, 0..) |upper, i| {
|
||||
sum += @atomicLoad(usize, &self.buckets[i], .monotonic);
|
||||
try writer.print(name ++ "_bucket{{le=\"" ++ std.fmt.comptimePrint("{d}", .{upper}) ++ "\"}} {d}\n", .{sum});
|
||||
}
|
||||
|
||||
const count = @atomicLoad(usize, &self.count, .monotonic);
|
||||
try writer.print(
|
||||
name ++ "_bucket{{le=\"+Inf\"}} {d}\n" ++ name ++ "_sum {d}\n" ++ name ++ "_count {d}\n",
|
||||
.{ count, @atomicLoad(usize, &self.sum, .monotonic), count },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "Metrics: Counter" {
|
||||
var w = std.Io.Writer.Allocating.init(testing.allocator);
|
||||
defer w.deinit();
|
||||
|
||||
var c = Counter{};
|
||||
c.incr();
|
||||
c.incrBy(3);
|
||||
try c.write("stat", "counts stats", &w.writer);
|
||||
try testing.expectEqual(
|
||||
\\# HELP stat_total counts stats
|
||||
\\# TYPE stat_total counter
|
||||
\\stat_total 4
|
||||
\\
|
||||
, w.written());
|
||||
}
|
||||
|
||||
test "Metrics: Gauge" {
|
||||
var w = std.Io.Writer.Allocating.init(testing.allocator);
|
||||
defer w.deinit();
|
||||
|
||||
var g = Gauge{};
|
||||
g.incr();
|
||||
g.incr();
|
||||
g.decr();
|
||||
try g.write("stat", "counts stats", &w.writer);
|
||||
try testing.expectEqual(
|
||||
\\# HELP stat counts stats
|
||||
\\# TYPE stat gauge
|
||||
\\stat 1
|
||||
\\
|
||||
, w.written());
|
||||
}
|
||||
|
||||
test "Metrics: CounterEnum" {
|
||||
var w = std.Io.Writer.Allocating.init(testing.allocator);
|
||||
defer w.deinit();
|
||||
|
||||
var c = CounterEnum("color", enum { red, blue }){};
|
||||
c.incr(.blue);
|
||||
c.incrBy(.blue, 2);
|
||||
try c.write("stat", "counts stats", &w.writer);
|
||||
try testing.expectEqual(
|
||||
\\# HELP stat_total counts stats
|
||||
\\# TYPE stat_total counter
|
||||
\\stat_total{color="red"} 0
|
||||
\\stat_total{color="blue"} 3
|
||||
\\
|
||||
, w.written());
|
||||
}
|
||||
|
||||
test "Metrics: Histogram" {
|
||||
var w = std.Io.Writer.Allocating.init(testing.allocator);
|
||||
defer w.deinit();
|
||||
|
||||
var h = Histogram(&.{ 10, 100 }){};
|
||||
h.observe(5);
|
||||
h.observe(10); // le is inclusive
|
||||
h.observe(50);
|
||||
h.observe(200); // +Inf only
|
||||
try h.write("stat", "counts stats", &w.writer);
|
||||
try testing.expectEqual(
|
||||
\\# HELP stat counts stats
|
||||
\\# TYPE stat histogram
|
||||
\\stat_bucket{le="10"} 2
|
||||
\\stat_bucket{le="100"} 3
|
||||
\\stat_bucket{le="+Inf"} 4
|
||||
\\stat_sum 265
|
||||
\\stat_count 4
|
||||
\\
|
||||
, w.written());
|
||||
}
|
||||
@@ -21,8 +21,7 @@ const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("browser/js/js.zig");
|
||||
const Frame = @import("browser/Frame.zig");
|
||||
const Transfer = @import("browser/HttpClient.zig").Transfer;
|
||||
const Response = @import("browser/HttpClient.zig").Response;
|
||||
const Transfer = @import("network/HttpClient.zig").Transfer;
|
||||
|
||||
const log = lp.log;
|
||||
const Execution = js.Execution;
|
||||
@@ -76,6 +75,7 @@ const EventListeners = struct {
|
||||
frame_created: List = .{},
|
||||
frame_navigate: List = .{},
|
||||
frame_navigated: List = .{},
|
||||
frame_navigated_within_document: List = .{},
|
||||
frame_navigate_failed: List = .{},
|
||||
frame_network_idle: List = .{},
|
||||
frame_network_almost_idle: List = .{},
|
||||
@@ -105,6 +105,7 @@ const Events = union(enum) {
|
||||
frame_created: *Frame,
|
||||
frame_navigate: *const FrameNavigate,
|
||||
frame_navigated: *const FrameNavigated,
|
||||
frame_navigated_within_document: *const FrameNavigatedWithinDocument,
|
||||
frame_navigate_failed: *const FrameNavigateFailed,
|
||||
frame_network_idle: *const FrameNetworkIdle,
|
||||
frame_network_almost_idle: *const FrameNetworkAlmostIdle,
|
||||
@@ -155,6 +156,23 @@ pub const FrameNavigated = struct {
|
||||
opts: Frame.NavigatedOpts,
|
||||
};
|
||||
|
||||
// A same-document navigation (History pushState/replaceState, fragment
|
||||
// changes, or history traversal). The document and its execution context
|
||||
// stay alive — only the URL changes — so CDP must emit
|
||||
// Page.navigatedWithinDocument WITHOUT clearing the execution context or
|
||||
// sending DOM.documentUpdated.
|
||||
pub const FrameNavigatedWithinDocument = struct {
|
||||
frame_id: u32,
|
||||
url: [:0]const u8,
|
||||
navigation_type: NavigationType = .other,
|
||||
|
||||
pub const NavigationType = enum {
|
||||
fragment,
|
||||
historyApi,
|
||||
other,
|
||||
};
|
||||
};
|
||||
|
||||
// A root navigation that failed before commit (DNS failure, connection
|
||||
// refused, malformed response, ...). Dispatched so CDP can answer the
|
||||
// pending Page.navigate command with an errorText instead of leaving the
|
||||
@@ -224,7 +242,6 @@ pub const ResponseData = struct {
|
||||
|
||||
pub const ResponseHeaderDone = struct {
|
||||
transfer: *Transfer,
|
||||
response: *const Response,
|
||||
};
|
||||
|
||||
pub const RequestDone = struct {
|
||||
|
||||
@@ -139,7 +139,7 @@ fn walk(
|
||||
if (html_el.getHidden()) return;
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
const text = text_node.getWholeText();
|
||||
const text = text_node.ownData();
|
||||
if (isAllWhitespace(text)) {
|
||||
return;
|
||||
}
|
||||
@@ -414,7 +414,7 @@ const JsonVisitor = struct {
|
||||
try self.jw.objectField("nodeType");
|
||||
try self.jw.write(3);
|
||||
try self.jw.objectField("nodeValue");
|
||||
try self.jw.write(text_node.getWholeText());
|
||||
try self.jw.write(text_node.ownData());
|
||||
} else {
|
||||
try self.jw.objectField("nodeType");
|
||||
try self.jw.write(9);
|
||||
@@ -472,7 +472,7 @@ const TextVisitor = struct {
|
||||
name_to_print = n;
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
|
||||
const trimmed = std.mem.trim(u8, text_node.ownData(), " \t\r\n");
|
||||
if (trimmed.len > 0) {
|
||||
name_to_print = trimmed;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ fn spawnWorker(self: *Server, socket: posix.socket_t) !void {
|
||||
while (current < self.max_connections) {
|
||||
current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break;
|
||||
} else {
|
||||
lp.metrics.cdp_connection_limit.incr();
|
||||
return error.MaxThreadsReached;
|
||||
}
|
||||
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
|
||||
@@ -218,6 +219,12 @@ fn handleConnection(self: *Server, socket: posix.socket_t) void {
|
||||
return;
|
||||
}
|
||||
|
||||
// only count websocket (i.e. CDP) connections, not HTTP requests like
|
||||
// /json/version probes or /metrics scrapes
|
||||
lp.metrics.cdp_connections.incr();
|
||||
lp.metrics.cdp_active_connections.incr();
|
||||
defer lp.metrics.cdp_active_connections.decr();
|
||||
|
||||
{
|
||||
// Transition from .handshake state to .live
|
||||
// Lock needed even though the main thread hasn't seen this yet because
|
||||
@@ -276,6 +283,7 @@ fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 {
|
||||
"\"Browser\": \"Lightpanda/1.0\", " ++
|
||||
"\"Protocol-Version\": \"1.3\", " ++
|
||||
"\"User-Agent\": \"Lightpanda/1.0\", " ++
|
||||
"\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++
|
||||
"\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++
|
||||
"}}";
|
||||
const body_len = std.fmt.count(body_format, .{ host, port });
|
||||
@@ -309,6 +317,7 @@ test "server: buildJSONVersionResponse" {
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"Browser\": \"Lightpanda/") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"Protocol-Version\": \"1.3\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"User-Agent\": \"Lightpanda/") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"") != null);
|
||||
}
|
||||
|
||||
@@ -551,6 +560,18 @@ test "server: get /json/version" {
|
||||
}
|
||||
}
|
||||
|
||||
test "server: get /metrics" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest("GET /metrics HTTP/1.1\r\n\r\n");
|
||||
std.debug.print("1\n", .{});
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n"));
|
||||
try testing.expect(std.mem.indexOf(u8, res, "Content-Type: text/plain; version=0.0.4") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "build_info{version=") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, res, "# TYPE cdp_connections_total counter") != null);
|
||||
}
|
||||
|
||||
fn assertHTTPError(
|
||||
comptime expected_status: u16,
|
||||
comptime expected_body: []const u8,
|
||||
@@ -648,7 +669,7 @@ fn createTestClient() !TestClient {
|
||||
|
||||
const TestClient = struct {
|
||||
stream: std.net.Stream,
|
||||
buf: [1024]u8 = undefined,
|
||||
buf: [8192]u8 = undefined,
|
||||
reader: WS.Reader(false),
|
||||
|
||||
const WS = @import("network/WS.zig");
|
||||
@@ -664,10 +685,11 @@ const TestClient = struct {
|
||||
var pos: usize = 0;
|
||||
var total_length: ?usize = null;
|
||||
while (true) {
|
||||
pos += try self.stream.read(self.buf[pos..]);
|
||||
if (pos == 0) {
|
||||
return error.NoMoreData;
|
||||
const n = try self.stream.read(self.buf[pos..]);
|
||||
if (n == 0) {
|
||||
return if (pos == self.buf.len) error.MessageTooLarge else error.NoMoreData;
|
||||
}
|
||||
pos += n;
|
||||
const response = self.buf[0..pos];
|
||||
if (total_length == null) {
|
||||
const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue;
|
||||
|
||||
@@ -35,8 +35,10 @@ const App = @import("../App.zig");
|
||||
const CDPNode = @import("../cdp/Node.zig");
|
||||
const Conversation = @import("Conversation.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const ansi = @import("ansi.zig");
|
||||
const SlashCommand = @import("SlashCommand.zig");
|
||||
const settings = @import("settings.zig");
|
||||
const picker = @import("picker.zig");
|
||||
const save = @import("save.zig");
|
||||
const welcome = @import("welcome.zig");
|
||||
const string = @import("../string.zig");
|
||||
@@ -75,135 +77,15 @@ const default_system_prompt = browser_tools.driver_guidance ++
|
||||
\\- If the user asks for account-scoped data (karma, profile, inbox, …)
|
||||
\\ and the page shows you're not signed in, log in proactively (per
|
||||
\\ the Credentials section above) before reporting unavailable.
|
||||
;
|
||||
\\
|
||||
++ lp.skill.semantics_note;
|
||||
|
||||
/// Skill-like documentation for writing Lightpanda agent script
|
||||
/// Used in the system prompt of the `/save` command.
|
||||
const script_skill =
|
||||
\\# Writing Lightpanda agent scripts
|
||||
\\
|
||||
\\Run with:
|
||||
\\
|
||||
\\```console
|
||||
\\./lightpanda agent script.js
|
||||
\\```
|
||||
\\
|
||||
\\## Mental model (get this right first)
|
||||
\\
|
||||
\\The script runs in its **own V8 context** — neither the page nor Node.js:
|
||||
\\
|
||||
\\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`.
|
||||
\\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`.
|
||||
\\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …).
|
||||
\\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed.
|
||||
\\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2).
|
||||
\\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables.
|
||||
\\- Variables persist across navigations within one run, so cross-page aggregation is plain JS.
|
||||
\\- **`return <value>` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`.
|
||||
\\
|
||||
\\## Primitives
|
||||
\\
|
||||
\\`Page` is the only global; `new Page()` makes a page and everything else is a method on it.
|
||||
\\
|
||||
\\| Call | Notes |
|
||||
\\|------|-------|
|
||||
\\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). |
|
||||
\\| `await page.goto(url[, { timeout }])` | **Async — must be `await`ed.** Navigates the page (re-navigating reuses the same object). Waits for `load`. Default timeout 10000 ms. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). |
|
||||
\\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. |
|
||||
\\| `page.extract(schema)` | The only primitive returning a real JS value (object/array). See schema below. |
|
||||
\\| `page.evaluate(script[, { url, timeout, save }])` | Page-side JS escape hatch; returns text (JSON for objects/arrays). |
|
||||
\\| `page.click(sel)` / `page.hover(sel)` | |
|
||||
\\| `page.fill(sel, value)` / `page.selectOption(sel, value)` | |
|
||||
\\| `page.setChecked(sel[, checked])` | `checked` defaults to `true`. |
|
||||
\\| `page.press(sel, key)` / `page.press(null, key)` / `page.press({ key })` | Selector first! `page.press("Enter")` binds "Enter" to `selector` and fails. |
|
||||
\\| `page.scroll()` / `page.scroll({ x, y })` | |
|
||||
\\| `page.waitForSelector(sel[, { timeout }])` | `waitFor*` default timeout 5000 ms. |
|
||||
\\| `page.waitForScript(js[, { timeout }])` | Re-evaluates page JS until truthy. |
|
||||
\\| `page.waitForState(state[, { timeout }])` | `"load"`, `"domcontentloaded"`, `"networkalmostidle"`, `"networkidle"`, `"done"`. |
|
||||
\\
|
||||
\\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable.
|
||||
\\
|
||||
\\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`.
|
||||
\\
|
||||
\\## extract schema
|
||||
\\
|
||||
\\Keys = output field names; values pick what to lift (not a JSON Schema):
|
||||
\\
|
||||
\\```js
|
||||
\\const { stories } = page.extract({
|
||||
\\ stories: [{
|
||||
\\ selector: "tr.athing", // one record per match
|
||||
\\ limit: 5,
|
||||
\\ fields: { // resolved relative to each match
|
||||
\\ title: ".titleline > a", // first match's text (null if missing)
|
||||
\\ url: { selector: ".titleline > a", attr: "href" },
|
||||
\\ text: "" // "" = the matched element's own text
|
||||
\\ }
|
||||
\\ }]
|
||||
\\});
|
||||
\\```
|
||||
\\
|
||||
\\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form.
|
||||
\\- Every value is a string or null — parse numbers in script logic.
|
||||
\\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty.
|
||||
\\- An object schema always returns an object (destructure it); a bare array schema returns the array directly.
|
||||
\\- No `save:` option in scripts — keep results in variables.
|
||||
\\
|
||||
\\## Best practices
|
||||
\\
|
||||
\\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly.
|
||||
\\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another:
|
||||
\\ ```js
|
||||
\\ const list = new Page();
|
||||
\\ await list.goto(listUrl);
|
||||
\\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] });
|
||||
\\
|
||||
\\ const pages = items.map(() => new Page());
|
||||
\\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once
|
||||
\\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) }));
|
||||
\\ ```
|
||||
\\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed.
|
||||
\\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine.
|
||||
\\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state.
|
||||
\\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist.
|
||||
\\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process.
|
||||
\\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`.
|
||||
\\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`).
|
||||
\\7. **End with `return <result>`.** `console.log` is for debug output only and doesn't JSON-format objects.
|
||||
\\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent.
|
||||
\\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines:
|
||||
\\ ```js
|
||||
\\ // Load the Hacker News front page
|
||||
\\ const page = new Page();
|
||||
\\ await page.goto("https://news.ycombinator.com");
|
||||
\\
|
||||
\\ // Pull the top 5 stories (title + link)
|
||||
\\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] });
|
||||
\\
|
||||
\\ // Open each story page in parallel and read its text
|
||||
\\ const pages = stories.map(() => new Page());
|
||||
\\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url)));
|
||||
\\ ```
|
||||
\\
|
||||
\\## Common errors
|
||||
\\
|
||||
\\| Error | Cause / fix |
|
||||
\\|-------|-------------|
|
||||
\\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` |
|
||||
\\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` |
|
||||
\\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first |
|
||||
\\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. |
|
||||
\\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` |
|
||||
\\| `require is not defined` | Not Node.js |
|
||||
\\| `no page loaded - run page.goto(url) first` | Page method before navigation |
|
||||
\\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options |
|
||||
\\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors |
|
||||
\\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` |
|
||||
;
|
||||
|
||||
// Sytem prompt of the `/save` command
|
||||
// With the save instructions and the skill-like agent script documentation.
|
||||
const save_system_prompt = browser_tools.save_synthesis_prompt ++ "\n\n" ++ script_skill;
|
||||
// System prompt of the `/save` command: the save instructions plus the
|
||||
// script skill (`lp.skill`), whose primitives reference is rendered from
|
||||
// the tool schemas at first use — hence lazy rather than comptime.
|
||||
var save_prompts_once = std.once(initSavePrompts);
|
||||
var save_system_prompt: []const u8 = undefined;
|
||||
var save_revision_system_prompt: []const u8 = undefined;
|
||||
|
||||
// Swapped in instead of `save_system_prompt` when the user message carries a
|
||||
// previously saved script: the "this session" framing above would otherwise
|
||||
@@ -216,7 +98,23 @@ const save_revision_note =
|
||||
\\a change, and add what this session contributes. Output the complete
|
||||
\\updated script — never a fragment, diff, or continuation.
|
||||
;
|
||||
const save_revision_system_prompt = browser_tools.save_synthesis_prompt ++ "\n" ++ save_revision_note ++ "\n" ++ script_skill;
|
||||
|
||||
/// Panics on OOM like `Schema.all()` — the inputs are static, process-lifetime
|
||||
/// strings, so failure is a build bug, not a runtime condition.
|
||||
fn initSavePrompts() void {
|
||||
const a = std.heap.page_allocator;
|
||||
save_system_prompt = std.mem.concat(a, u8, &.{
|
||||
browser_tools.save_synthesis_prompt, "\n\n", lp.skill.text(),
|
||||
}) catch @panic("OOM building save prompt");
|
||||
save_revision_system_prompt = std.mem.concat(a, u8, &.{
|
||||
browser_tools.save_synthesis_prompt, "\n", save_revision_note, "\n", lp.skill.text(),
|
||||
}) catch @panic("OOM building save prompt");
|
||||
}
|
||||
|
||||
fn savePrompt(revision: bool) []const u8 {
|
||||
save_prompts_once.call();
|
||||
return if (revision) save_revision_system_prompt else save_system_prompt;
|
||||
}
|
||||
|
||||
const synthesis_prompt =
|
||||
\\You have used your tool budget or cannot finish the exploration.
|
||||
@@ -276,6 +174,15 @@ synthetic_tool_call_id: u32 = 0,
|
||||
total_usage: zenai.provider.Usage = .{},
|
||||
/// Set when the last turn ended in a model refusal (safety stop).
|
||||
last_turn_refused: bool = false,
|
||||
/// Whether assistant text streams to the terminal as the model produces it.
|
||||
/// Toggled via `/stream`; persisted in `.lp-agent.zon`.
|
||||
stream_enabled: bool,
|
||||
/// True while assistant text is streaming to stdout (spinner paused). Cleared
|
||||
/// by `endStreamedText`, which also emits the closing newline.
|
||||
stream_active: bool = false,
|
||||
/// True when any assistant text streamed during the current turn, so `runTurn`
|
||||
/// skips the buffered `printAssistant` that would double-print it.
|
||||
streamed_text: bool = false,
|
||||
available_providers: []const []const u8,
|
||||
/// Cached reachability of each `local_providers` entry, so the per-keystroke
|
||||
/// `/provider` hinter probes each local server at most once.
|
||||
@@ -388,10 +295,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
|
||||
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null);
|
||||
const verbosity = settings.resolveVerbosity(opts, remembered);
|
||||
const stream_enabled = settings.resolveStream(remembered);
|
||||
|
||||
if (resolved) |r| {
|
||||
if (r.source == .picked) {
|
||||
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity }) catch {};
|
||||
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {};
|
||||
}
|
||||
// provider/model now live in the status bar; just space before the help
|
||||
std.debug.print("\n", .{});
|
||||
@@ -427,6 +335,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
.conversation = .init(allocator, opts.system_prompt orelse default_system_prompt),
|
||||
.model = model,
|
||||
.effort = effort,
|
||||
.stream_enabled = stream_enabled,
|
||||
.script_file = opts.script_file,
|
||||
.one_shot_task = opts.task,
|
||||
.one_shot_save = opts.save,
|
||||
@@ -449,12 +358,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
|
||||
|
||||
if (will_repl) {
|
||||
self.terminal.attachCompleter();
|
||||
self.terminal.completion_source = .{
|
||||
self.terminal.attachCompleter(.{
|
||||
.context = @ptrCast(self),
|
||||
.providers = completionProviders,
|
||||
.models = completionModels,
|
||||
};
|
||||
});
|
||||
// The model-list cache fills lazily on the first `/model` completion,
|
||||
// so startup never blocks on the network.
|
||||
Terminal.setIdleCallback(&idlePump, @ptrCast(self));
|
||||
@@ -564,12 +472,43 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
|
||||
/// The side effects of `drainCancellation` without surfacing the error, for
|
||||
/// void callers (e.g. `/save` synthesis) that just need to clean up.
|
||||
fn resetAfterCancel(self: *Agent, baseline: usize) void {
|
||||
self.endStreamedText();
|
||||
self.conversation.rollback(baseline);
|
||||
self.browser.env.cancelTerminate();
|
||||
self.cancel_requested.store(false, .release);
|
||||
self.http_interrupt.reset();
|
||||
}
|
||||
|
||||
/// On the first delta it pauses the spinner, whose stderr frames would
|
||||
/// otherwise interleave with this stdout text.
|
||||
fn streamAssistantDelta(ctx: *anyopaque, delta: []const u8) void {
|
||||
const self: *Agent = @ptrCast(@alignCast(ctx));
|
||||
if (delta.len == 0) return;
|
||||
if (!self.stream_active) {
|
||||
self.terminal.spinner.pause();
|
||||
self.stream_active = true;
|
||||
}
|
||||
self.streamed_text = true;
|
||||
self.terminal.printAssistantDelta(delta);
|
||||
}
|
||||
|
||||
/// Close an in-progress streamed line with a newline. Idempotent, so every
|
||||
/// `runTools` exit path can call it unconditionally.
|
||||
fn endStreamedText(self: *Agent) void {
|
||||
if (!self.stream_active) return;
|
||||
self.terminal.endAssistantStream();
|
||||
self.stream_active = false;
|
||||
}
|
||||
|
||||
/// The text-delta hook, or null when `/stream off` — a null hook makes
|
||||
/// `runTools` fall back to a buffered response. Streaming is an interactive
|
||||
/// REPL affordance; one-shot (`--task`) and script modes keep stdout to the
|
||||
/// buffered final answer so wrappers can parse it cleanly.
|
||||
fn streamHook(self: *Agent) ?zenai.provider.Client.TextDeltaHook {
|
||||
if (!self.stream_enabled or !self.terminal.isRepl()) return null;
|
||||
return .{ .context = @ptrCast(self), .onText = streamAssistantDelta };
|
||||
}
|
||||
|
||||
/// One agent turn: the prompt sent to the model, plus optional context — a
|
||||
/// recorder comment to write before the turn, file attachments to bundle into
|
||||
/// the first user message, and a display label used in error output.
|
||||
@@ -639,9 +578,14 @@ fn runTurn(self: *Agent, input: TurnInput) bool {
|
||||
},
|
||||
};
|
||||
if (!input.suppress_answer) {
|
||||
if (text) |t|
|
||||
self.terminal.printAssistant(t)
|
||||
else if (self.last_turn_refused)
|
||||
if (text) |t| {
|
||||
// Streaming already emitted the text incrementally (plus a closing
|
||||
// newline); only the buffered path needs to print it here. Safe as a
|
||||
// turn-wide flag: the stream accumulator reconstructs `t` from the
|
||||
// same deltas it emitted, and the synthesis fallback also streams, so
|
||||
// `streamed_text` implies `t` was already shown in full.
|
||||
if (!self.streamed_text) self.terminal.printAssistant(t);
|
||||
} else if (self.last_turn_refused)
|
||||
self.terminal.printInfo("(model declined to respond — safety refusal)", .{})
|
||||
else
|
||||
self.terminal.printInfo("(no response from model)", .{});
|
||||
@@ -654,8 +598,7 @@ fn runRepl(self: *Agent) void {
|
||||
log.debug(.app, "tools loaded", .{ .count = globalTools().len });
|
||||
|
||||
if (self.ai_client != null) {
|
||||
const a = Terminal.ansi;
|
||||
std.debug.print(" model: {s}{s} {s}effort: {s}{s}{s}\n", .{ a.dim, self.model, a.reset, a.dim, @tagName(self.effort), a.reset });
|
||||
std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset });
|
||||
}
|
||||
|
||||
repl: while (true) {
|
||||
@@ -770,6 +713,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.
|
||||
.help => self.printSlashHelp(arena, rest),
|
||||
.verbosity => self.setEnumOption("verbosity", &self.terminal.verbosity, rest),
|
||||
.effort => self.setEnumOption("effort", &self.effort, rest),
|
||||
.stream => self.handleStream(rest),
|
||||
.usage => self.handleUsage(),
|
||||
.clear => self.handleClear(),
|
||||
.reset => self.handleReset(),
|
||||
@@ -798,6 +742,24 @@ fn setEnumOption(self: *Agent, comptime name: []const u8, target: anytype, rest:
|
||||
self.reportSaved(name, @tagName(level));
|
||||
}
|
||||
|
||||
/// `/stream`: bare prints the current state; `on`/`off` sets and persists it.
|
||||
fn handleStream(self: *Agent, rest: []const u8) void {
|
||||
if (rest.len == 0) {
|
||||
self.terminal.printInfo("stream: {s}", .{if (self.stream_enabled) "on" else "off"});
|
||||
return;
|
||||
}
|
||||
const on = if (std.ascii.eqlIgnoreCase(rest, "on") or std.ascii.eqlIgnoreCase(rest, "true"))
|
||||
true
|
||||
else if (std.ascii.eqlIgnoreCase(rest, "off") or std.ascii.eqlIgnoreCase(rest, "false"))
|
||||
false
|
||||
else {
|
||||
self.terminal.printError("usage: /stream [on|off] (got {s})", .{rest});
|
||||
return;
|
||||
};
|
||||
self.stream_enabled = on;
|
||||
self.reportSaved("stream", if (on) "on" else "off");
|
||||
}
|
||||
|
||||
/// Print cumulative session token usage, broken down so the cache's effect is
|
||||
/// visible — the REPL otherwise never surfaces the `$usage` line `--task`
|
||||
/// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`);
|
||||
@@ -908,7 +870,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void {
|
||||
self.terminal.printInfo("{s}: {s}", .{ label, value });
|
||||
return;
|
||||
}
|
||||
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity })) {
|
||||
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) {
|
||||
self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path });
|
||||
} else |_| {
|
||||
self.terminal.printInfo("{s}: {s}", .{ label, value });
|
||||
@@ -1131,7 +1093,7 @@ fn promptSaveMode(self: *Agent, path: []const u8) ?save.Mode {
|
||||
"append — add the recorded commands at the end",
|
||||
"replace — overwrite with the recorded commands",
|
||||
};
|
||||
const idx = Terminal.promptNumberedChoice(header, labels, 0) catch {
|
||||
const idx = picker.promptNumberedChoice(header, labels, 0) catch {
|
||||
self.terminal.printInfo("Save cancelled.", .{});
|
||||
return null;
|
||||
};
|
||||
@@ -1212,7 +1174,7 @@ fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mo
|
||||
// regular turns keep the driver prompt. (`messages[0]` is the system
|
||||
// message — rollback and prune never touch it.)
|
||||
const plain_system = self.conversation.messages.items[0].content;
|
||||
self.conversation.messages.items[0].content = if (previous_script != null) save_revision_system_prompt else save_system_prompt;
|
||||
self.conversation.messages.items[0].content = savePrompt(previous_script != null);
|
||||
defer self.conversation.messages.items[0].content = plain_system;
|
||||
|
||||
const ma = self.conversation.arena.allocator();
|
||||
@@ -1366,6 +1328,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
|
||||
"/effort " ++ Config.tagHint(Config.Effort) ++ " — set per-turn reasoning effort (currently: {s}); saved to {s}. Bare /effort prints the level.",
|
||||
.{ @tagName(self.effort), settings.remembered_path },
|
||||
),
|
||||
.stream => self.terminal.printInfo(
|
||||
"/stream [on|off] — stream assistant text as it's generated (currently: {s}); saved to {s}. Bare /stream prints the state.",
|
||||
.{ if (self.stream_enabled) "on" else "off", settings.remembered_path },
|
||||
),
|
||||
.usage => self.terminal.printInfo(
|
||||
"/usage — show cumulative token usage and cache hit rate for this session",
|
||||
.{},
|
||||
@@ -1406,7 +1372,7 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
|
||||
}
|
||||
}
|
||||
const tool_schema = Schema.findByName(target) orelse {
|
||||
if (Terminal.closestCommand(target)) |near| {
|
||||
if (SlashCommand.closestCommand(target)) |near| {
|
||||
self.terminal.printError("unknown command: {s}. Did you mean " ++ Terminal.highlightCmd("/help {s}") ++ "?", .{ target, near });
|
||||
} else {
|
||||
self.terminal.printError("unknown command: {s}", .{target});
|
||||
@@ -1601,6 +1567,11 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []
|
||||
fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
const ma = self.conversation.arena.allocator();
|
||||
self.api_error_detail = null;
|
||||
self.streamed_text = false;
|
||||
// Defensive: if an exit path ever missed `endStreamedText`, a stale-true
|
||||
// `stream_active` would skip the spinner pause on the next turn's first
|
||||
// delta and let frames interleave with the text. Reset it at the source.
|
||||
self.stream_active = false;
|
||||
self.http_interrupt.reset();
|
||||
|
||||
try self.conversation.ensureSystemPrompt();
|
||||
@@ -1646,8 +1617,12 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
// non-thinking models.
|
||||
.effort = self.effort,
|
||||
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
||||
// Suppressed turns (e.g. `--save` capture) must keep stdout clean;
|
||||
// streaming would bypass the `suppress_answer` guard in `runTurn`.
|
||||
.stream = if (input.suppress_answer) null else self.streamHook(),
|
||||
},
|
||||
) catch |err| {
|
||||
self.endStreamedText();
|
||||
self.terminal.spinner.cancel();
|
||||
// Ctrl-C can land while runTools unwinds an HTTP error — surface
|
||||
// UserCancelled, not ApiError, so the user sees the outcome they asked for.
|
||||
@@ -1657,6 +1632,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
self.conversation.rollback(msg_baseline);
|
||||
return error.ApiError;
|
||||
};
|
||||
self.endStreamedText();
|
||||
self.terminal.spinner.stop();
|
||||
defer result.deinit();
|
||||
self.total_usage.add(result.usage);
|
||||
@@ -1730,13 +1706,16 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
|
||||
// `.none` stays off to opt out on models that reject it.
|
||||
.effort = if (self.effort == .none) .none else .low,
|
||||
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
|
||||
.stream = if (input.suppress_answer) null else self.streamHook(),
|
||||
},
|
||||
) catch |err| {
|
||||
self.endStreamedText();
|
||||
if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline);
|
||||
log.err(.app, "AI synthesis error", .{ .err = err });
|
||||
self.conversation.rollback(synth_baseline);
|
||||
break :blk null;
|
||||
};
|
||||
self.endStreamedText();
|
||||
defer synth.deinit();
|
||||
self.total_usage.add(synth.usage);
|
||||
|
||||
@@ -1819,6 +1798,8 @@ fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 {
|
||||
|
||||
fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) zenai.provider.Client.ToolHandler.Result {
|
||||
const self: *Agent = @ptrCast(@alignCast(ctx));
|
||||
// Close any assistant text streamed this turn before the tool spinner shows.
|
||||
self.endStreamedText();
|
||||
// The spinner doesn't render args, and `agentToolDone` skips the body line
|
||||
// at low verbosity — don't pay for the stringify when nobody reads it.
|
||||
const needs_args = self.terminal.spinner.isEnabled() or self.terminal.verbosity != .low;
|
||||
@@ -1932,6 +1913,17 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u
|
||||
test {
|
||||
_ = save;
|
||||
_ = settings;
|
||||
_ = picker;
|
||||
}
|
||||
|
||||
test "savePrompt: save instructions followed by the rendered script skill" {
|
||||
const prompt = savePrompt(false);
|
||||
try std.testing.expect(std.mem.startsWith(u8, prompt, browser_tools.save_synthesis_prompt));
|
||||
try std.testing.expect(std.mem.endsWith(u8, prompt, lp.skill.text()));
|
||||
|
||||
const revision = savePrompt(true);
|
||||
try std.testing.expect(std.mem.indexOf(u8, revision, save_revision_note) != null);
|
||||
try std.testing.expect(std.mem.endsWith(u8, revision, lp.skill.text()));
|
||||
}
|
||||
|
||||
test "capToolOutput: passes through when under cap" {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
//! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`,
|
||||
//! `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
|
||||
//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
|
||||
//! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema
|
||||
//! primitives live in `lp.Schema`; import that directly.
|
||||
|
||||
@@ -46,7 +46,7 @@ pub const MetaCommand = struct {
|
||||
|
||||
/// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta
|
||||
/// command is a compile error until it's wired up there too.
|
||||
const Tag = enum { help, quit, verbosity, effort, usage, clear, reset, save, load, model, provider };
|
||||
const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider };
|
||||
};
|
||||
|
||||
const tagNames = Config.tagNames;
|
||||
@@ -57,6 +57,7 @@ pub const meta_commands = [_]MetaCommand{
|
||||
.{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" },
|
||||
.{ .tag = .verbosity, .name = "verbosity", .hint = tagHint(Config.AgentVerbosity), .values = tagNames(Config.AgentVerbosity), .description = "Set agent verbosity" },
|
||||
.{ .tag = .effort, .name = "effort", .hint = tagHint(Config.Effort), .values = tagNames(Config.Effort), .description = "Set per-turn reasoning effort" },
|
||||
.{ .tag = .stream, .name = "stream", .hint = "[on|off]", .values = &.{ "on", "off" }, .description = "Toggle streaming of assistant text" },
|
||||
.{ .tag = .usage, .name = "usage", .hint = "", .values = &.{}, .description = "Show token usage and cache stats for this session" },
|
||||
.{ .tag = .clear, .name = "clear", .hint = "", .values = &.{}, .description = "Clear conversation history and usage (keeps page/cookies)" },
|
||||
.{ .tag = .reset, .name = "reset", .hint = "", .values = &.{}, .description = "Reset conversation and browser session (drops page/cookies)" },
|
||||
@@ -81,3 +82,56 @@ pub fn findMeta(name: []const u8) ?*const MetaCommand {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const browser_tools = lp.tools;
|
||||
const llm_values = std.enums.values(Command.LlmCommand);
|
||||
|
||||
/// Every slash-invocable name: browser tools, LLM triggers, meta commands.
|
||||
pub const all_names: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = blk: {
|
||||
var arr: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = undefined;
|
||||
var idx: usize = 0;
|
||||
for (browser_tools.names) |n| {
|
||||
arr[idx] = n;
|
||||
idx += 1;
|
||||
}
|
||||
for (llm_values) |lc| {
|
||||
arr[idx] = @tagName(lc);
|
||||
idx += 1;
|
||||
}
|
||||
for (meta_commands) |m| {
|
||||
arr[idx] = m.name;
|
||||
idx += 1;
|
||||
}
|
||||
break :blk arr;
|
||||
};
|
||||
|
||||
/// Closest command name within two edits, or null — for "did you mean?" on typos.
|
||||
pub fn closestCommand(name: []const u8) ?[]const u8 {
|
||||
var best: ?[]const u8 = null;
|
||||
var best_dist: usize = std.math.maxInt(usize);
|
||||
for (all_names) |cand| {
|
||||
const dist = editDistance(name, cand);
|
||||
if (dist < best_dist) {
|
||||
best_dist = dist;
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
return if (best_dist <= 2) best else null;
|
||||
}
|
||||
|
||||
/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer
|
||||
/// than the table (no slash command is that long).
|
||||
fn editDistance(a: []const u8, b: []const u8) usize {
|
||||
const max = 32;
|
||||
if (a.len >= max or b.len >= max) return std.math.maxInt(usize);
|
||||
var dp: [max][max]usize = undefined;
|
||||
for (0..a.len + 1) |i| dp[i][0] = i;
|
||||
for (0..b.len + 1) |j| dp[0][j] = j;
|
||||
for (a, 1..) |ca, i| {
|
||||
for (b, 1..) |cb, j| {
|
||||
const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1;
|
||||
dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
|
||||
}
|
||||
}
|
||||
return dp[a.len][b.len];
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const log = lp.log;
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const ansi = Terminal.ansi;
|
||||
const ansi = @import("ansi.zig");
|
||||
const truncateUtf8 = @import("../string.zig").truncateUtf8;
|
||||
|
||||
const Spinner = @This();
|
||||
@@ -70,6 +69,11 @@ mu: std.Thread.Mutex = .{},
|
||||
cv: std.Thread.Condition = .{},
|
||||
state: State = .idle,
|
||||
frame: u8 = 0,
|
||||
/// True while a caller streams assistant text to stdout: the worker stops
|
||||
/// rendering and the current frame is cleared, so the stderr spinner does not
|
||||
/// interleave with the streamed line. Turn state (timer, tool count) is
|
||||
/// preserved for the eventual `stop`.
|
||||
paused: bool = false,
|
||||
|
||||
tool_calls: u32 = 0,
|
||||
turn_started_ns: i128 = 0,
|
||||
@@ -105,6 +109,7 @@ pub fn start(self: *Spinner) void {
|
||||
self.mu.lock();
|
||||
defer self.mu.unlock();
|
||||
self.state = .thinking;
|
||||
self.paused = false;
|
||||
self.frame = 0;
|
||||
self.tool_calls = 0;
|
||||
self.turn_started_ns = std.time.nanoTimestamp();
|
||||
@@ -143,6 +148,7 @@ pub fn stop(self: *Spinner) void {
|
||||
_ = std.posix.write(std.posix.STDERR_FILENO, summary) catch {};
|
||||
|
||||
self.state = .idle;
|
||||
self.paused = false;
|
||||
self.last_render_len = 0;
|
||||
}
|
||||
|
||||
@@ -155,9 +161,25 @@ pub fn cancel(self: *Spinner) void {
|
||||
if (self.state == .idle) return;
|
||||
_ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {};
|
||||
self.state = .idle;
|
||||
self.paused = false;
|
||||
self.last_render_len = 0;
|
||||
}
|
||||
|
||||
/// Clear the current frame and stop rendering without ending the turn, so the
|
||||
/// caller can stream text to stdout. The next explicit state change
|
||||
/// (`setTool`/`stop`/`cancel`/`start`) clears `paused` and resumes the
|
||||
/// animation. No-op when idle or already paused.
|
||||
pub fn pause(self: *Spinner) void {
|
||||
if (!self.isEnabled()) return;
|
||||
self.mu.lock();
|
||||
defer self.mu.unlock();
|
||||
if (self.state == .idle or self.paused) return;
|
||||
self.paused = true;
|
||||
_ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {};
|
||||
self.last_render_len = 0;
|
||||
self.cv.signal();
|
||||
}
|
||||
|
||||
/// Switch the indicator to "running tool <name> <args>". Counts toward the
|
||||
/// turn's tool-call total. Args are truncated to `max_args_bytes`. Called
|
||||
/// without a preceding `start()` (state `.idle`), the label drops the `agent:`
|
||||
@@ -166,6 +188,8 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void {
|
||||
if (!self.isEnabled()) return;
|
||||
self.mu.lock();
|
||||
defer self.mu.unlock();
|
||||
// A tool call ends any in-progress text stream; resume normal rendering.
|
||||
self.paused = false;
|
||||
const manual = self.state == .idle;
|
||||
self.tool_calls += 1;
|
||||
var tool: ToolState = .{ .set_ns = std.time.nanoTimestamp(), .manual = manual };
|
||||
@@ -224,7 +248,7 @@ fn workerLoop(self: *Spinner) void {
|
||||
self.mu.lock();
|
||||
defer self.mu.unlock();
|
||||
while (!self.should_exit) {
|
||||
while (!self.should_exit and self.state == .idle) self.cv.wait(&self.mu);
|
||||
while (!self.should_exit and (self.state == .idle or self.paused)) self.cv.wait(&self.mu);
|
||||
if (self.should_exit) return;
|
||||
|
||||
switch (self.state) {
|
||||
@@ -266,7 +290,7 @@ fn renderLocked(self: *Spinner) void {
|
||||
// (glyph, two spaces, `[`, `]`) around prefix+name+args. `\r` and
|
||||
// ANSI escapes are zero-width, so they don't count toward wrap.
|
||||
const decoration_cells: usize = 5 + prefix.len + name.len;
|
||||
const cols: usize = Terminal.columns() orelse 80;
|
||||
const cols: usize = columns() orelse 80;
|
||||
// Reserve one extra cell so the line is strictly less than `cols`:
|
||||
// auto-wrap (DECAWM) terminals advance past a row that exactly fills
|
||||
// the width.
|
||||
@@ -288,6 +312,21 @@ fn renderLocked(self: *Spinner) void {
|
||||
_ = std.posix.write(std.posix.STDERR_FILENO, written) catch {};
|
||||
}
|
||||
|
||||
/// Current terminal width in columns, queried via TIOCGWINSZ on stderr.
|
||||
/// Null when stderr isn't a tty, the ioctl fails, or the kernel reports 0
|
||||
/// (some pseudo-ttys leave the field unset). Cheap enough to call per render
|
||||
/// frame; picks up resizes without SIGWINCH plumbing.
|
||||
fn columns() ?u16 {
|
||||
var ws: std.posix.winsize = undefined;
|
||||
// bitcast via c_uint: on archs where `_IOR` sets the direction bit
|
||||
// (MIPS/PPC/SPARC), `IOCGWINSZ` exceeds i32 range, so a plain @intCast
|
||||
// panics; the bitcast preserves the bit pattern.
|
||||
const req: c_int = @bitCast(@as(c_uint, std.posix.T.IOCGWINSZ));
|
||||
const rc = std.c.ioctl(std.posix.STDERR_FILENO, req, &ws);
|
||||
if (rc != 0 or ws.col == 0) return null;
|
||||
return ws.col;
|
||||
}
|
||||
|
||||
/// Returns the byte length of `bytes` that fits in `max_cells` cells, rounded
|
||||
/// down to a whole UTF-8 codepoint. Multi-cell glyphs (CJK, wide emoji) count
|
||||
/// as 1 — args are typically ASCII, so the approximation is good enough.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
39
src/agent/ansi.zig
Normal file
39
src/agent/ansi.zig
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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/>.
|
||||
|
||||
pub const reset = "\x1b[0m";
|
||||
pub const bold = "\x1b[1m";
|
||||
pub const dim = "\x1b[2m";
|
||||
pub const italic = "\x1b[3m";
|
||||
pub const underline = "\x1b[4m";
|
||||
pub const strike = "\x1b[9m";
|
||||
// Color names follow isocline's (bbcode_colors.c): teal is the dark cyan
|
||||
// pair of bright cyan, like maroon/red or olive/yellow.
|
||||
pub const teal = "\x1b[36m";
|
||||
pub const cyan = "\x1b[96m";
|
||||
pub const green = "\x1b[32m";
|
||||
pub const yellow = "\x1b[33m";
|
||||
pub const red = "\x1b[31m";
|
||||
pub const blue = "\x1b[34m";
|
||||
pub const magenta = "\x1b[35m";
|
||||
pub const clear_eol = "\x1b[K";
|
||||
pub const clear_line = "\x1b[2K";
|
||||
// Kitty keyboard protocol; each push must pair with a pop.
|
||||
pub const kitty_disambiguate = "\x1b[>1u";
|
||||
pub const kitty_legacy = "\x1b[>0u";
|
||||
pub const kitty_pop = "\x1b[<u";
|
||||
269
src/agent/js_highlight.zig
Normal file
269
src/agent/js_highlight.zig
Normal file
@@ -0,0 +1,269 @@
|
||||
// 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 string = @import("../string.zig");
|
||||
|
||||
pub const Kind = enum { comment, string, variable, interpolation, number, keyword, global, function, method, type_name };
|
||||
|
||||
/// Carried between calls because fenced code is tokenized one line at a time:
|
||||
/// block comments and template literals outlive a line boundary.
|
||||
pub const State = enum { normal, block_comment, template };
|
||||
|
||||
pub const StringSpan = struct { end: usize, closed: bool };
|
||||
|
||||
/// Scan the quoted run opening at `text[start]`. Escapes are not honored —
|
||||
/// good enough for coloring, not parsing.
|
||||
pub fn scanString(text: []const u8, start: usize) StringSpan {
|
||||
if (start >= text.len) return .{ .end = start, .closed = false };
|
||||
const close = std.mem.indexOfScalarPos(u8, text, start + 1, text[start]) orelse
|
||||
return .{ .end = text.len, .closed = false };
|
||||
return .{ .end = close + 1, .closed = true };
|
||||
}
|
||||
|
||||
/// Index just past the `$name` ref opening at `text[start]` (scanning within
|
||||
/// `text[..end]`), or `start + 1` when the `$` is bare. A ref is a `$` followed
|
||||
/// by at least one `[A-Za-z0-9_]`.
|
||||
fn dollarRefEnd(text: []const u8, start: usize, end: usize) usize {
|
||||
var i = start + 1;
|
||||
while (i < end and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
pub const DollarRef = struct { start: usize, end: usize, kind: Kind };
|
||||
|
||||
/// Next `$name` (`.variable`) ref at or after `from` within `text[..end]`,
|
||||
/// or null; bare `$`s are skipped. `interpolation` additionally recognizes
|
||||
/// `${…}` (`.interpolation`, unclosed runs to `end`) — only template
|
||||
/// literals interpolate, so callers pass their quote kind.
|
||||
pub fn nextDollarRef(text: []const u8, from: usize, end: usize, interpolation: bool) ?DollarRef {
|
||||
var i = from;
|
||||
while (i < end) {
|
||||
if (text[i] != '$') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (interpolation and i + 1 < end and text[i + 1] == '{') {
|
||||
const close = std.mem.indexOfScalarPos(u8, text[0..end], i + 2, '}');
|
||||
return .{ .start = i, .end = if (close) |c| c + 1 else end, .kind = .interpolation };
|
||||
}
|
||||
const ref_end = dollarRefEnd(text, i, end);
|
||||
if (ref_end > i + 1) return .{ .start = i, .end = ref_end, .kind = .variable };
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Tokenize `text` as JavaScript starting from `state`, reporting spans to
|
||||
/// `sink.emit(start, len, kind)` and returning the state at end of input.
|
||||
/// Spans arrive in order and never overlap — the gaps between them are plain
|
||||
/// text — so an emitter that writes sequentially (ANSI) works as well as one
|
||||
/// that paints cells (isocline).
|
||||
pub fn tokenize(text: []const u8, state: State, sink: anytype) State {
|
||||
var i: usize = 0;
|
||||
|
||||
switch (state) {
|
||||
.block_comment => {
|
||||
const close = std.mem.indexOfPos(u8, text, 0, "*/");
|
||||
i = if (close) |p| p + 2 else text.len;
|
||||
if (i > 0) sink.emit(0, i, .comment);
|
||||
if (close == null) return .block_comment;
|
||||
},
|
||||
.template => {
|
||||
const close = std.mem.indexOfScalarPos(u8, text, 0, '`');
|
||||
i = if (close) |p| p + 1 else text.len;
|
||||
emitString(text, 0, i, true, sink);
|
||||
if (close == null) return .template;
|
||||
},
|
||||
.normal => {},
|
||||
}
|
||||
|
||||
while (i < text.len) {
|
||||
const ch = text[i];
|
||||
if (ch == '/' and i + 1 < text.len and (text[i + 1] == '/' or text[i + 1] == '*')) {
|
||||
const start = i;
|
||||
if (text[i + 1] == '/') {
|
||||
i = std.mem.indexOfScalarPos(u8, text, i + 2, '\n') orelse text.len;
|
||||
sink.emit(start, i - start, .comment);
|
||||
continue;
|
||||
}
|
||||
const close = std.mem.indexOfPos(u8, text, i + 2, "*/");
|
||||
i = if (close) |p| p + 2 else text.len;
|
||||
sink.emit(start, i - start, .comment);
|
||||
if (close == null) return .block_comment;
|
||||
continue;
|
||||
}
|
||||
if (ch == '\'' or ch == '"' or ch == '`') {
|
||||
const span = scanString(text, i);
|
||||
emitString(text, i, span.end, ch == '`', sink);
|
||||
i = span.end;
|
||||
// Only a template literal may legally continue on the next line.
|
||||
if (!span.closed and ch == '`') return .template;
|
||||
continue;
|
||||
}
|
||||
if (ch == '$') {
|
||||
const start = i;
|
||||
i = dollarRefEnd(text, i, text.len);
|
||||
if (i > start + 1) sink.emit(start, i - start, .variable);
|
||||
continue;
|
||||
}
|
||||
if (std.ascii.isDigit(ch) or (ch == '.' and i + 1 < text.len and std.ascii.isDigit(text[i + 1]))) {
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < text.len and (std.ascii.isHex(text[i]) or text[i] == '.' or text[i] == '_' or text[i] == 'x' or text[i] == 'X')) i += 1;
|
||||
sink.emit(start, i - start, .number);
|
||||
continue;
|
||||
}
|
||||
if (std.ascii.isAlphabetic(ch) or ch == '_') {
|
||||
const start = i;
|
||||
i += 1;
|
||||
while (i < text.len and isIdChar(text[i])) i += 1;
|
||||
const tok = text[start..i];
|
||||
if (string.isOneOf(tok, &keywords)) {
|
||||
sink.emit(start, i - start, .keyword);
|
||||
} else if (string.isOneOf(tok, &globals) and (start == 0 or text[start - 1] != '.')) {
|
||||
// `.document` is a property access, not the global
|
||||
sink.emit(start, i - start, .global);
|
||||
} else if (i < text.len and text[i] == '(') {
|
||||
const kind: Kind = if (std.ascii.isUpper(tok[0]))
|
||||
.type_name
|
||||
else if (start > 0 and text[start - 1] == '.')
|
||||
.method
|
||||
else
|
||||
.function;
|
||||
sink.emit(start, i - start, kind);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return .normal;
|
||||
}
|
||||
|
||||
/// Emit `text[start..end]` as string spans split around any `$name` refs —
|
||||
/// spans must never overlap, so a sequential writer (ANSI) works as well as
|
||||
/// isocline's last-write-wins cell painting.
|
||||
fn emitString(text: []const u8, start: usize, end: usize, interpolation: bool, sink: anytype) void {
|
||||
var seg = start;
|
||||
while (nextDollarRef(text, seg, end, interpolation)) |ref| {
|
||||
if (ref.start > seg) sink.emit(seg, ref.start - seg, .string);
|
||||
sink.emit(ref.start, ref.end - ref.start, ref.kind);
|
||||
seg = ref.end;
|
||||
}
|
||||
if (end > seg) sink.emit(seg, end - seg, .string);
|
||||
}
|
||||
|
||||
const keywords = [_][]const u8{
|
||||
"function", "async", "await", "yield", "return", "if", "else",
|
||||
"for", "while", "do", "switch", "case", "break", "continue",
|
||||
"var", "let", "const", "new", "delete", "typeof", "instanceof",
|
||||
"in", "of", "void", "this", "super", "class", "extends",
|
||||
"import", "export", "from", "default", "try", "catch", "finally",
|
||||
"throw", "true", "false", "null", "undefined", "NaN", "Infinity",
|
||||
};
|
||||
|
||||
// Globals available in the JS-mode page context; highlighted so it's visible
|
||||
// at the prompt that they're in scope.
|
||||
const globals = [_][]const u8{ "document", "window", "globalThis", "console", "lp" };
|
||||
|
||||
fn isIdChar(ch: u8) bool {
|
||||
return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '$' or ch >= 0x80;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Records spans as `kind:text` so tests read as the tokenization, not offsets.
|
||||
const TestSink = struct {
|
||||
text: []const u8,
|
||||
buf: std.ArrayListUnmanaged(u8) = .empty,
|
||||
last_end: usize = 0,
|
||||
overlapped: bool = false,
|
||||
|
||||
fn emit(self: *TestSink, start: usize, len: usize, kind: Kind) void {
|
||||
if (start < self.last_end) self.overlapped = true;
|
||||
self.last_end = start + len;
|
||||
self.buf.writer(testing.allocator).print("{s}:{s} ", .{
|
||||
@tagName(kind), self.text[start..][0..len],
|
||||
}) catch unreachable;
|
||||
}
|
||||
};
|
||||
|
||||
fn expectTokens(expected: []const u8, src: []const u8) !void {
|
||||
var sink: TestSink = .{ .text = src };
|
||||
defer sink.buf.deinit(testing.allocator);
|
||||
_ = tokenize(src, .normal, &sink);
|
||||
try testing.expect(!sink.overlapped);
|
||||
try testing.expectEqualStrings(expected, std.mem.trimRight(u8, sink.buf.items, " "));
|
||||
}
|
||||
|
||||
test "js_highlight: keywords, globals, numbers" {
|
||||
try expectTokens("keyword:const number:1", "const x = 1");
|
||||
try expectTokens("global:document", "document.body");
|
||||
// A property access is not the global.
|
||||
try expectTokens("", "el.document");
|
||||
}
|
||||
|
||||
test "js_highlight: comments and strings" {
|
||||
try expectTokens("comment:// hi", "// hi");
|
||||
try expectTokens("comment:/* hi */ keyword:const", "/* hi */ const");
|
||||
try expectTokens("string:'hi'", "'hi'");
|
||||
}
|
||||
|
||||
test "js_highlight: calls color by identifier case" {
|
||||
try expectTokens("function:markdown string:'x'", "markdown('x')");
|
||||
try expectTokens("global:console method:log", "console.log(x)");
|
||||
try expectTokens("keyword:new type_name:URL", "new URL(u)");
|
||||
// A keyword before `(` stays a keyword; a bare identifier stays plain.
|
||||
try expectTokens("keyword:if", "if (x)");
|
||||
try expectTokens("", "markdown");
|
||||
}
|
||||
|
||||
test "js_highlight: $refs inside strings do not overlap" {
|
||||
try expectTokens("string:'a variable:$LP_KEY string:'", "'a $LP_KEY'");
|
||||
try expectTokens("variable:$LP_KEY", "$LP_KEY");
|
||||
}
|
||||
|
||||
test "js_highlight: template interpolations split string spans" {
|
||||
try expectTokens("string:`a interpolation:${x.y} string:`", "`a ${x.y}`");
|
||||
// Unclosed interpolation runs to end of line.
|
||||
try expectTokens("string:`a interpolation:${x", "`a ${x");
|
||||
// Only template literals interpolate.
|
||||
try expectTokens("string:'a ${x}'", "'a ${x}'");
|
||||
}
|
||||
|
||||
test "js_highlight: block comment spans lines" {
|
||||
var sink: TestSink = .{ .text = "/* open" };
|
||||
defer sink.buf.deinit(testing.allocator);
|
||||
try testing.expectEqual(State.block_comment, tokenize("/* open", .normal, &sink));
|
||||
|
||||
var sink2: TestSink = .{ .text = "still */ const" };
|
||||
defer sink2.buf.deinit(testing.allocator);
|
||||
try testing.expectEqual(State.normal, tokenize("still */ const", .block_comment, &sink2));
|
||||
try testing.expectEqualStrings("comment:still */ keyword:const", std.mem.trimRight(u8, sink2.buf.items, " "));
|
||||
}
|
||||
|
||||
test "js_highlight: template literal spans lines" {
|
||||
var sink: TestSink = .{ .text = "`<div>" };
|
||||
defer sink.buf.deinit(testing.allocator);
|
||||
try testing.expectEqual(State.template, tokenize("`<div>", .normal, &sink));
|
||||
|
||||
var sink2: TestSink = .{ .text = "</div>` + x" };
|
||||
defer sink2.buf.deinit(testing.allocator);
|
||||
try testing.expectEqual(State.normal, tokenize("</div>` + x", .template, &sink2));
|
||||
try testing.expectEqualStrings("string:</div>`", std.mem.trimRight(u8, sink2.buf.items, " "));
|
||||
}
|
||||
903
src/agent/md_term.zig
Normal file
903
src/agent/md_term.zig
Normal file
@@ -0,0 +1,903 @@
|
||||
// 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 ansi = @import("ansi.zig");
|
||||
const js_highlight = @import("js_highlight.zig");
|
||||
|
||||
/// Render markdown `src` as ANSI-styled terminal output to `w` — one-shot
|
||||
/// wrapper over `Stream`, so batch and streamed output share one dispatcher.
|
||||
pub fn render(w: *std.Io.Writer, src: []const u8) !void {
|
||||
var s: Stream = .{};
|
||||
try s.feed(w, src);
|
||||
try s.close(w);
|
||||
}
|
||||
|
||||
const LineIterator = std.mem.SplitIterator(u8, .scalar);
|
||||
|
||||
const max_table_columns = 16;
|
||||
|
||||
/// Shown while a streamed table is withheld; written without a newline so
|
||||
/// `clear_placeholder` can erase it in place before the table renders.
|
||||
const table_placeholder = ansi.dim ++ "… rendering table" ++ ansi.reset;
|
||||
const clear_placeholder = "\r" ++ ansi.clear_line;
|
||||
|
||||
/// Cells are inline-rendered and padded to per-column visible width. Widths
|
||||
/// count codepoints, so double-width glyphs (CJK, emoji) may misalign.
|
||||
fn renderTable(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void {
|
||||
var widths: [max_table_columns]usize = @splat(0);
|
||||
const cols = measureTable(header, it.*, &widths) orelse
|
||||
return renderTableVerbatim(w, header, it);
|
||||
|
||||
try emitRow(w, header, widths[0..cols], true);
|
||||
_ = it.next();
|
||||
try w.writeByte('\n');
|
||||
try emitSeparator(w, widths[0..cols]);
|
||||
while (it.peek()) |line| {
|
||||
if (!isTableRow(line)) break;
|
||||
_ = it.next();
|
||||
try w.writeByte('\n');
|
||||
try emitRow(w, line, widths[0..cols], false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for tables the aligner can't measure (too many columns, or a
|
||||
/// rendered cell overflowing its fixed buffer): rows pass through untouched.
|
||||
fn renderTableVerbatim(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void {
|
||||
try styled(w, header, ansi.bold);
|
||||
while (it.peek()) |line| {
|
||||
if (!isTableRow(line) and !isTableSeparator(line)) break;
|
||||
_ = it.next();
|
||||
try w.writeByte('\n');
|
||||
if (isTableSeparator(line)) {
|
||||
try styled(w, line, ansi.dim);
|
||||
} else {
|
||||
try w.writeAll(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-column visible widths for the whole table (`it` positioned on the
|
||||
/// separator row), or null when it can't be aligned: no cells, too many
|
||||
/// columns, or a cell overflowing the rendering buffer.
|
||||
fn measureTable(header: []const u8, it: LineIterator, widths: *[max_table_columns]usize) ?usize {
|
||||
var cols = measureRow(header, widths, true) orelse return null;
|
||||
var scan = it;
|
||||
_ = scan.next();
|
||||
while (scan.peek()) |line| {
|
||||
if (!isTableRow(line)) break;
|
||||
_ = scan.next();
|
||||
cols = @max(cols, measureRow(line, widths, false) orelse return null);
|
||||
}
|
||||
return if (cols == 0) null else cols;
|
||||
}
|
||||
|
||||
/// Column count of `row`, or null when the table can't be aligned (too many
|
||||
/// columns, or a cell overflowing the rendering buffer).
|
||||
fn measureRow(row: []const u8, widths: *[max_table_columns]usize, is_header: bool) ?usize {
|
||||
var cells = cellIterator(row);
|
||||
var col: usize = 0;
|
||||
while (cells.next()) |cell| {
|
||||
if (col == max_table_columns) return null;
|
||||
const width = cellDisplayWidth(cell, is_header) orelse return null;
|
||||
widths[col] = @max(widths[col], width);
|
||||
col += 1;
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: bool) !void {
|
||||
var cells = cellIterator(row);
|
||||
for (widths) |width| {
|
||||
try styled(w, "│", ansi.dim);
|
||||
try w.writeByte(' ');
|
||||
const cell = cells.next() orelse "";
|
||||
var used: usize = 0;
|
||||
if (cell.len > 0) {
|
||||
// Render into scratch so `used` is measured from the exact bytes
|
||||
// written. measureRow rendered the identical bytes into an
|
||||
// identically-sized buffer, so this cannot overflow here.
|
||||
var buf: [cell_buf_len]u8 = undefined;
|
||||
var fw: std.Io.Writer = .fixed(&buf);
|
||||
try renderCell(&fw, cell, is_header);
|
||||
try w.writeAll(fw.buffered());
|
||||
used = visibleWidth(fw.buffered());
|
||||
}
|
||||
try w.splatByteAll(' ', width - used + 1);
|
||||
}
|
||||
try styled(w, "│", ansi.dim);
|
||||
}
|
||||
|
||||
fn renderCell(w: *std.Io.Writer, cell: []const u8, is_header: bool) std.Io.Writer.Error!void {
|
||||
if (is_header) return span(w, cell, ansi.bold, null);
|
||||
try renderInline(w, cell);
|
||||
}
|
||||
|
||||
fn emitSeparator(w: *std.Io.Writer, widths: []const usize) !void {
|
||||
try w.writeAll(ansi.dim);
|
||||
for (widths, 0..) |width, col| {
|
||||
try w.writeAll(if (col == 0) "├" else "┼");
|
||||
try w.splatBytesAll("─", width + 2);
|
||||
}
|
||||
try w.writeAll("┤");
|
||||
try w.writeAll(ansi.reset);
|
||||
}
|
||||
|
||||
const CellIterator = struct {
|
||||
row: []const u8,
|
||||
pos: usize,
|
||||
|
||||
fn next(self: *CellIterator) ?[]const u8 {
|
||||
if (self.pos >= self.row.len) return null;
|
||||
var i = self.pos;
|
||||
var in_code = false;
|
||||
while (i < self.row.len) : (i += 1) {
|
||||
switch (self.row[i]) {
|
||||
'`' => in_code = !in_code,
|
||||
'\\' => i += 1,
|
||||
'|' => if (!in_code) break,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
const end = @min(i, self.row.len);
|
||||
const cell = std.mem.trim(u8, self.row[self.pos..end], " \t");
|
||||
self.pos = i + 1;
|
||||
// A row's trailing `|` leaves one empty last segment; drop it.
|
||||
if (cell.len == 0 and i >= self.row.len) return null;
|
||||
return cell;
|
||||
}
|
||||
};
|
||||
|
||||
fn cellIterator(row: []const u8) CellIterator {
|
||||
const first_pipe = std.mem.indexOfScalar(u8, row, '|');
|
||||
return .{ .row = row, .pos = if (first_pipe) |p| p + 1 else 0 };
|
||||
}
|
||||
|
||||
const cell_buf_len = 1024;
|
||||
|
||||
fn cellDisplayWidth(cell: []const u8, is_header: bool) ?usize {
|
||||
var buf: [cell_buf_len]u8 = undefined;
|
||||
var fw: std.Io.Writer = .fixed(&buf);
|
||||
renderCell(&fw, cell, is_header) catch return null;
|
||||
return visibleWidth(fw.buffered());
|
||||
}
|
||||
|
||||
/// Display columns of rendered output: escapes are zero, codepoints are one.
|
||||
fn visibleWidth(s: []const u8) usize {
|
||||
var n: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i < s.len) {
|
||||
if (s[i] == 0x1b and i + 1 < s.len) {
|
||||
switch (s[i + 1]) {
|
||||
'[' => {
|
||||
i += 2;
|
||||
while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
},
|
||||
']' => {
|
||||
i += 2;
|
||||
while (i < s.len and s[i] != 0x07 and s[i] != 0x1b) i += 1;
|
||||
i += @as(usize, if (i < s.len and s[i] == 0x1b) 2 else 1);
|
||||
continue;
|
||||
},
|
||||
'\\' => {
|
||||
i += 2;
|
||||
continue;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
if (s[i] & 0xc0 != 0x80) n += 1;
|
||||
i += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
fn isFenceDelimiter(line: []const u8) bool {
|
||||
return std.mem.startsWith(u8, std.mem.trimLeft(u8, line, " \t"), "```");
|
||||
}
|
||||
|
||||
/// Shared by the fence rules and the `---` horizontal rule so they line up.
|
||||
const rule_width = 24;
|
||||
|
||||
/// Dim `╭── lang ───` / `╰─────` rule marking where a fenced code block starts
|
||||
/// and ends. The opening rule carries the fence's info-string language, if any.
|
||||
fn renderFenceRule(w: *std.Io.Writer, opening: bool, delimiter: []const u8) !void {
|
||||
try w.writeAll(ansi.dim);
|
||||
try w.writeAll(if (opening) "╭" else "╰");
|
||||
var fill: usize = rule_width - 1;
|
||||
if (opening) {
|
||||
const info = std.mem.trim(u8, std.mem.trimLeft(u8, delimiter, " \t`"), " \t");
|
||||
const lang = info[0 .. std.mem.indexOfAny(u8, info, " \t") orelse info.len];
|
||||
if (lang.len > 0 and lang.len + 4 <= fill) {
|
||||
try w.writeAll("─ ");
|
||||
try w.writeAll(lang);
|
||||
try w.writeByte(' ');
|
||||
fill -= lang.len + 3;
|
||||
}
|
||||
}
|
||||
try w.splatBytesAll("─", fill);
|
||||
try w.writeAll(ansi.reset);
|
||||
}
|
||||
|
||||
fn isTableRow(line: []const u8) bool {
|
||||
const trimmed = std.mem.trimLeft(u8, line, " \t");
|
||||
return trimmed.len >= 1 and trimmed[0] == '|';
|
||||
}
|
||||
|
||||
/// A row of `-`, `:`, `|` and spaces with at least one dash and one pipe,
|
||||
/// e.g. `| --- |:---:|`.
|
||||
fn isTableSeparator(line: []const u8) bool {
|
||||
var has_dash = false;
|
||||
var has_pipe = false;
|
||||
for (line) |c| switch (c) {
|
||||
'-' => has_dash = true,
|
||||
'|' => has_pipe = true,
|
||||
':', ' ', '\t' => {},
|
||||
else => return false,
|
||||
};
|
||||
return has_dash and has_pipe;
|
||||
}
|
||||
|
||||
/// Incremental renderer for streamed deltas: buffers until each newline,
|
||||
/// then renders the completed line. Fence state carries across lines. Table
|
||||
/// rows are withheld and re-rendered aligned once the table ends — alignment
|
||||
/// needs the whole table, so it can't stream row by row.
|
||||
pub const Stream = struct {
|
||||
/// Show a progress marker while a streamed table is withheld, erased in
|
||||
/// place before the table renders. Only for streaming to an interactive
|
||||
/// terminal (the erase assumes the cursor still sits on the marker);
|
||||
/// off, withheld tables stay silent until they render.
|
||||
show_table_placeholder: bool = false,
|
||||
len: usize = 0,
|
||||
in_fence: bool = false,
|
||||
/// Fenced-code lexer state, carried across lines and chunks.
|
||||
js_state: js_highlight.State = .normal,
|
||||
/// A line that outgrew `buf` passes through unrendered to its newline.
|
||||
raw: bool = false,
|
||||
/// `held`: one pipe row buffered, pending the separator that confirms a
|
||||
/// table. `table`: rows accumulate in `table_buf` until the table ends.
|
||||
mode: enum { text, held, table } = .text,
|
||||
table_len: usize = 0,
|
||||
buf: [4096]u8 = undefined,
|
||||
/// A table that outgrows this falls back to per-line rendering.
|
||||
table_buf: [16384]u8 = undefined,
|
||||
|
||||
pub fn feed(self: *Stream, w: *std.Io.Writer, data: []const u8) !void {
|
||||
var rest = data;
|
||||
while (std.mem.indexOfScalar(u8, rest, '\n')) |nl| {
|
||||
const head = rest[0..nl];
|
||||
rest = rest[nl + 1 ..];
|
||||
if (self.raw) {
|
||||
try w.writeAll(head);
|
||||
try w.writeByte('\n');
|
||||
self.raw = false;
|
||||
} else if (self.len == 0) {
|
||||
try self.emitLine(w, head);
|
||||
} else if (self.len + head.len <= self.buf.len) {
|
||||
@memcpy(self.buf[self.len..][0..head.len], head);
|
||||
const full = self.buf[0 .. self.len + head.len];
|
||||
self.len = 0;
|
||||
try self.emitLine(w, full);
|
||||
} else {
|
||||
try w.writeAll(self.buf[0..self.len]);
|
||||
try w.writeAll(head);
|
||||
try w.writeByte('\n');
|
||||
self.len = 0;
|
||||
}
|
||||
}
|
||||
if (rest.len == 0) return;
|
||||
if (self.raw) {
|
||||
try w.writeAll(rest);
|
||||
} else if (self.len + rest.len <= self.buf.len) {
|
||||
@memcpy(self.buf[self.len..][0..rest.len], rest);
|
||||
self.len += rest.len;
|
||||
} else {
|
||||
try w.writeAll(self.buf[0..self.len]);
|
||||
try w.writeAll(rest);
|
||||
self.len = 0;
|
||||
self.raw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush any withheld table and a trailing partial line (no newline is
|
||||
/// written for it), then reset all state for the next message.
|
||||
pub fn close(self: *Stream, w: *std.Io.Writer) !void {
|
||||
defer self.* = .{ .show_table_placeholder = self.show_table_placeholder };
|
||||
// A message often ends inside a table: adopt the partial last row.
|
||||
if (self.len > 0 and !self.raw) {
|
||||
const partial = self.buf[0..self.len];
|
||||
const adopt = switch (self.mode) {
|
||||
.table => isTableRow(partial),
|
||||
.held => isTableSeparator(partial),
|
||||
.text => false,
|
||||
};
|
||||
if (adopt and self.appendTableLine(partial)) {
|
||||
self.mode = .table;
|
||||
self.len = 0;
|
||||
}
|
||||
}
|
||||
switch (self.mode) {
|
||||
.text => {},
|
||||
.held => try self.releaseHeldRow(w),
|
||||
.table => try self.renderBufferedTable(w),
|
||||
}
|
||||
if (self.len == 0) return;
|
||||
const partial = self.buf[0..self.len];
|
||||
if (isFenceDelimiter(partial)) {
|
||||
if (self.in_fence) try w.writeByte('\n');
|
||||
return renderFenceRule(w, !self.in_fence, partial);
|
||||
}
|
||||
try renderLine(w, partial, if (self.in_fence) &self.js_state else null);
|
||||
}
|
||||
|
||||
fn emitLine(self: *Stream, w: *std.Io.Writer, text: []const u8) std.Io.Writer.Error!void {
|
||||
switch (self.mode) {
|
||||
.text => {
|
||||
if (isFenceDelimiter(text)) {
|
||||
self.in_fence = !self.in_fence;
|
||||
self.js_state = .normal;
|
||||
if (!self.in_fence) try w.writeByte('\n');
|
||||
try renderFenceRule(w, self.in_fence, text);
|
||||
try w.writeByte('\n');
|
||||
if (self.in_fence) try w.writeByte('\n');
|
||||
return;
|
||||
}
|
||||
if (!self.in_fence and isTableRow(text) and self.appendTableLine(text)) {
|
||||
self.mode = .held;
|
||||
return;
|
||||
}
|
||||
try renderLine(w, text, if (self.in_fence) &self.js_state else null);
|
||||
try w.writeByte('\n');
|
||||
},
|
||||
.held => {
|
||||
if (isTableSeparator(text) and self.appendTableLine(text)) {
|
||||
self.mode = .table;
|
||||
// Withholding goes quiet; leave a marker until the
|
||||
// table renders over it.
|
||||
if (self.show_table_placeholder) try w.writeAll(table_placeholder);
|
||||
return;
|
||||
}
|
||||
try self.releaseHeldRow(w);
|
||||
try self.emitLine(w, text);
|
||||
},
|
||||
.table => {
|
||||
if (isTableRow(text)) {
|
||||
if (self.appendTableLine(text)) return;
|
||||
try self.flushTableUnaligned(w);
|
||||
try renderLine(w, text, null);
|
||||
try w.writeByte('\n');
|
||||
return;
|
||||
}
|
||||
try self.renderBufferedTable(w);
|
||||
try self.emitLine(w, text);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// No separator followed, so the held row wasn't a table header.
|
||||
fn releaseHeldRow(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
try renderLine(w, self.tableText(), null);
|
||||
try w.writeByte('\n');
|
||||
self.resetTable();
|
||||
}
|
||||
|
||||
fn renderBufferedTable(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
if (self.show_table_placeholder) try w.writeAll(clear_placeholder);
|
||||
var it: LineIterator = std.mem.splitScalar(u8, self.tableText(), '\n');
|
||||
const header = it.first();
|
||||
try renderTable(w, header, &it);
|
||||
try w.writeByte('\n');
|
||||
self.resetTable();
|
||||
}
|
||||
|
||||
fn flushTableUnaligned(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
if (self.show_table_placeholder) try w.writeAll(clear_placeholder);
|
||||
var it = std.mem.splitScalar(u8, self.tableText(), '\n');
|
||||
while (it.next()) |line| {
|
||||
try renderLine(w, line, null);
|
||||
try w.writeByte('\n');
|
||||
}
|
||||
self.resetTable();
|
||||
}
|
||||
|
||||
fn appendTableLine(self: *Stream, line: []const u8) bool {
|
||||
const sep: usize = if (self.table_len == 0) 0 else 1;
|
||||
if (self.table_len + sep + line.len > self.table_buf.len) return false;
|
||||
if (sep == 1) {
|
||||
self.table_buf[self.table_len] = '\n';
|
||||
self.table_len += 1;
|
||||
}
|
||||
@memcpy(self.table_buf[self.table_len..][0..line.len], line);
|
||||
self.table_len += line.len;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn tableText(self: *const Stream) []const u8 {
|
||||
return self.table_buf[0..self.table_len];
|
||||
}
|
||||
|
||||
fn resetTable(self: *Stream) void {
|
||||
self.table_len = 0;
|
||||
self.mode = .text;
|
||||
}
|
||||
};
|
||||
|
||||
/// `js` carries the fenced-code lexer state across lines; null outside a fence.
|
||||
fn renderLine(w: *std.Io.Writer, line: []const u8, js: ?*js_highlight.State) !void {
|
||||
if (js) |state| {
|
||||
state.* = try renderCodeLine(w, line, state.*);
|
||||
return;
|
||||
}
|
||||
|
||||
const indent_len = line.len - std.mem.trimLeft(u8, line, " \t").len;
|
||||
const indent = line[0..indent_len];
|
||||
const trimmed = line[indent_len..];
|
||||
|
||||
if (trimmed.len >= 1 and trimmed[0] == '>') {
|
||||
try styled(w, "│", ansi.dim);
|
||||
try w.writeByte(' ');
|
||||
try renderInline(w, std.mem.trimLeft(u8, trimmed[1..], " "));
|
||||
return;
|
||||
}
|
||||
|
||||
// Dashed, unlike the solid fence rules, so adjacent ones read differently.
|
||||
if (isHorizontalRule(trimmed)) {
|
||||
try styled(w, "┄" ** rule_width, ansi.dim);
|
||||
return;
|
||||
}
|
||||
|
||||
var hashes: usize = 0;
|
||||
while (hashes < trimmed.len and trimmed[hashes] == '#') hashes += 1;
|
||||
if (hashes >= 1 and hashes <= 6 and hashes < trimmed.len and trimmed[hashes] == ' ') {
|
||||
try span(w, std.mem.trimLeft(u8, trimmed[hashes..], " "), ansi.bold, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and trimmed[1] == ' ') {
|
||||
try w.writeAll(indent);
|
||||
try styled(w, "•", ansi.dim);
|
||||
try w.writeByte(' ');
|
||||
try renderInline(w, std.mem.trimLeft(u8, trimmed[2..], " "));
|
||||
return;
|
||||
}
|
||||
|
||||
var digits: usize = 0;
|
||||
while (digits < trimmed.len and std.ascii.isDigit(trimmed[digits])) digits += 1;
|
||||
if (digits > 0 and digits + 1 < trimmed.len and
|
||||
(trimmed[digits] == '.' or trimmed[digits] == ')') and trimmed[digits + 1] == ' ')
|
||||
{
|
||||
try w.writeAll(indent);
|
||||
try w.writeAll(trimmed[0 .. digits + 2]);
|
||||
try renderInline(w, std.mem.trimLeft(u8, trimmed[digits + 2 ..], " "));
|
||||
return;
|
||||
}
|
||||
|
||||
try renderInline(w, line);
|
||||
}
|
||||
|
||||
/// Stack-threaded chain of enclosing span styles; a nested span's reset
|
||||
/// re-applies the whole chain, so styling survives arbitrary nesting depth.
|
||||
const Style = struct {
|
||||
code: []const u8,
|
||||
parent: ?*const Style,
|
||||
|
||||
fn apply(self: *const Style, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
if (self.parent) |p| try p.apply(w);
|
||||
try w.writeAll(self.code);
|
||||
}
|
||||
|
||||
fn applyOpt(active: ?*const Style, w: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
if (active) |a| try a.apply(w);
|
||||
}
|
||||
};
|
||||
|
||||
fn renderInline(w: *std.Io.Writer, text: []const u8) !void {
|
||||
try renderInlineStyled(w, text, null);
|
||||
}
|
||||
|
||||
fn renderInlineStyled(w: *std.Io.Writer, text: []const u8, active: ?*const Style) std.Io.Writer.Error!void {
|
||||
var i: usize = 0;
|
||||
while (i < text.len) {
|
||||
switch (text[i]) {
|
||||
// Only unescape markdown-special chars; leave e.g. `C:\Users` intact.
|
||||
'\\' => if (i + 1 < text.len and isEscapable(text[i + 1])) {
|
||||
try w.writeByte(text[i + 1]);
|
||||
i += 2;
|
||||
continue;
|
||||
},
|
||||
'`' => if (std.mem.indexOfPos(u8, text, i + 1, "`")) |end| {
|
||||
try styled(w, text[i + 1 .. end], ansi.teal);
|
||||
try Style.applyOpt(active, w);
|
||||
i = end + 1;
|
||||
continue;
|
||||
},
|
||||
'*', '_' => |ch| {
|
||||
const double = [2]u8{ ch, ch };
|
||||
if (i + 1 < text.len and text[i + 1] == ch) {
|
||||
if (std.mem.indexOfPos(u8, text, i + 2, &double)) |end| {
|
||||
try span(w, text[i + 2 .. end], ansi.bold, active);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
} else if (std.mem.indexOfScalarPos(u8, text, i + 1, ch)) |end| {
|
||||
try span(w, text[i + 1 .. end], ansi.italic, active);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
'~' => if (i + 1 < text.len and text[i + 1] == '~') {
|
||||
if (std.mem.indexOfPos(u8, text, i + 2, "~~")) |end| {
|
||||
try span(w, text[i + 2 .. end], ansi.strike, active);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
'[' => if (std.mem.indexOfPos(u8, text, i + 1, "](")) |mid| {
|
||||
if (std.mem.indexOfScalarPos(u8, text, mid + 2, ')')) |end| {
|
||||
try renderLink(w, text[i + 1 .. mid], text[mid + 2 .. end]);
|
||||
try Style.applyOpt(active, w);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
try w.writeByte(text[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn span(w: *std.Io.Writer, inner: []const u8, style: []const u8, active: ?*const Style) std.Io.Writer.Error!void {
|
||||
try w.writeAll(style);
|
||||
try renderInlineStyled(w, inner, &.{ .code = style, .parent = active });
|
||||
try w.writeAll(ansi.reset);
|
||||
try Style.applyOpt(active, w);
|
||||
}
|
||||
|
||||
/// Writes `js_highlight` spans as ANSI, filling the gaps between them with
|
||||
/// unstyled text. `emit` cannot fail, so a write error is stashed and returned
|
||||
/// by `finish`.
|
||||
const JsSink = struct {
|
||||
w: *std.Io.Writer,
|
||||
text: []const u8,
|
||||
last: usize = 0,
|
||||
err: ?std.Io.Writer.Error = null,
|
||||
|
||||
fn color(kind: js_highlight.Kind) []const u8 {
|
||||
return switch (kind) {
|
||||
.comment => ansi.dim ++ ansi.italic,
|
||||
.string => ansi.green,
|
||||
.variable, .interpolation => ansi.yellow,
|
||||
.number => ansi.magenta,
|
||||
.keyword => ansi.blue ++ ansi.bold,
|
||||
.global, .type_name => ansi.cyan,
|
||||
.function => ansi.teal,
|
||||
.method => ansi.teal ++ ansi.italic,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn emit(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) void {
|
||||
self.write(start, len, kind) catch |err| {
|
||||
self.err = self.err orelse err;
|
||||
};
|
||||
}
|
||||
|
||||
fn write(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) !void {
|
||||
if (start > self.last) try self.w.writeAll(self.text[self.last..start]);
|
||||
try styled(self.w, self.text[start..][0..len], color(kind));
|
||||
self.last = start + len;
|
||||
}
|
||||
|
||||
fn finish(self: *JsSink) !void {
|
||||
if (self.err) |err| return err;
|
||||
if (self.last < self.text.len) try self.w.writeAll(self.text[self.last..]);
|
||||
}
|
||||
};
|
||||
|
||||
/// Syntax-highlight one line of fenced code as JavaScript — the only language
|
||||
/// this agent emits in practice. Returns the lexer state for the next line.
|
||||
fn renderCodeLine(w: *std.Io.Writer, line: []const u8, state: js_highlight.State) !js_highlight.State {
|
||||
var sink: JsSink = .{ .w = w, .text = line };
|
||||
const next = js_highlight.tokenize(line, state, &sink);
|
||||
try sink.finish();
|
||||
return next;
|
||||
}
|
||||
|
||||
fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void {
|
||||
try w.writeAll(style);
|
||||
try w.writeAll(inner);
|
||||
try w.writeAll(ansi.reset);
|
||||
}
|
||||
|
||||
fn isEscapable(c: u8) bool {
|
||||
return switch (c) {
|
||||
'*', '_', '`', '~', '[', ']', '(', ')', '|', '\\' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// A left-trimmed line of 3+ identical `-`, `*` or `_` markers — the whole
|
||||
/// line, so `***bold***` stays inline text.
|
||||
fn isHorizontalRule(line: []const u8) bool {
|
||||
if (line.len < 3) return false;
|
||||
const first = line[0];
|
||||
if (first != '-' and first != '*' and first != '_') return false;
|
||||
for (line[1..]) |c| {
|
||||
if (c != first) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn renderLink(w: *std.Io.Writer, label: []const u8, url: []const u8) !void {
|
||||
// OSC 8 makes the label clickable where supported and is ignored elsewhere;
|
||||
// the trailing dim url is the fallback for terminals without OSC 8.
|
||||
try w.print("\x1b]8;;{s}\x1b\\", .{url});
|
||||
try styled(w, label, ansi.underline);
|
||||
try w.writeAll("\x1b]8;;\x1b\\");
|
||||
if (!std.mem.eql(u8, label, url)) try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset });
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn expectRender(expected: []const u8, src: []const u8) !void {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
try render(&aw.writer, src);
|
||||
try testing.expectEqualStrings(expected, aw.written());
|
||||
}
|
||||
|
||||
test "md_term: inline styles" {
|
||||
try expectRender("say \x1b[1mhi\x1b[0m now", "say **hi** now");
|
||||
try expectRender("say \x1b[3mhi\x1b[0m now", "say *hi* now");
|
||||
try expectRender("say \x1b[3mhi\x1b[0m now", "say _hi_ now");
|
||||
try expectRender("run \x1b[36mls\x1b[0m ok", "run `ls` ok");
|
||||
try expectRender("no \x1b[9mway\x1b[0m", "no ~~way~~");
|
||||
}
|
||||
|
||||
test "md_term: blocks" {
|
||||
try expectRender("\x1b[1mTitle\x1b[0m", "# Title");
|
||||
try expectRender("\x1b[1mSub\x1b[0m", "### Sub");
|
||||
try expectRender("\x1b[2m•\x1b[0m item", "- item");
|
||||
try expectRender("1. item", "1. item");
|
||||
}
|
||||
|
||||
test "md_term: alignment spaces after list marker collapse" {
|
||||
try expectRender("\x1b[2m•\x1b[0m item", "- item");
|
||||
try expectRender("1. item", "1. item");
|
||||
}
|
||||
|
||||
test "md_term: nested inline styles" {
|
||||
// The code span's reset re-applies the enclosing bold.
|
||||
try expectRender(
|
||||
"\x1b[1muse \x1b[36mls\x1b[0m\x1b[1m now\x1b[0m",
|
||||
"**use `ls` now**",
|
||||
);
|
||||
try expectRender(
|
||||
"\x1b[1m\x1b[36mgoto(url)\x1b[0m\x1b[1m\x1b[0m: nav",
|
||||
"**`goto(url)`**: nav",
|
||||
);
|
||||
// Inside a heading too: bold survives past the code span.
|
||||
try expectRender(
|
||||
"\x1b[1mrun \x1b[36mls\x1b[0m\x1b[1m first\x1b[0m",
|
||||
"## run `ls` first",
|
||||
);
|
||||
// Italic nested in bold stacks, then bold is restored.
|
||||
try expectRender(
|
||||
"\x1b[1ma \x1b[3mb\x1b[0m\x1b[1m c\x1b[0m",
|
||||
"**a *b* c**",
|
||||
);
|
||||
// Depth 2: the code span's reset re-applies the full bold+italic chain.
|
||||
try expectRender(
|
||||
"\x1b[1ma \x1b[3mb \x1b[36mc\x1b[0m\x1b[1m\x1b[3m d\x1b[0m\x1b[1m e\x1b[0m",
|
||||
"**a *b `c` d* e**",
|
||||
);
|
||||
}
|
||||
|
||||
const open_rule = "\x1b[2m╭" ++ "─" ** 23 ++ "\x1b[0m";
|
||||
const close_rule = "\x1b[2m╰" ++ "─" ** 23 ++ "\x1b[0m";
|
||||
|
||||
test "md_term: fenced code block is highlighted as JavaScript" {
|
||||
try expectRender(
|
||||
open_rule ++ "\n\n\x1b[34m\x1b[1mlet\x1b[0m x = \x1b[35m1\x1b[0m;\n\n" ++ close_rule,
|
||||
"```\nlet x = 1;\n```",
|
||||
);
|
||||
// Untokenized text passes through unstyled.
|
||||
try expectRender(open_rule ++ "\n\nplain\n\n" ++ close_rule, "```\nplain\n```");
|
||||
}
|
||||
|
||||
test "md_term: fence rules carry the language tag" {
|
||||
try expectRender(
|
||||
"\x1b[2m╭─ js " ++ "─" ** 18 ++ "\x1b[0m\n\nx\n\n" ++ close_rule,
|
||||
"```js\nx\n```",
|
||||
);
|
||||
// An overlong info string doesn't fit the rule and is dropped.
|
||||
try expectRender(
|
||||
open_rule ++ "\n\nx\n\n" ++ close_rule,
|
||||
"```" ++ "x" ** 20 ++ "\nx\n```",
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: fenced template literal spans lines" {
|
||||
try expectRender(
|
||||
open_rule ++ "\n\n\x1b[32m`<div>\x1b[0m\n\x1b[32m</div>`\x1b[0m\n\n" ++ close_rule,
|
||||
"```\n`<div>\n</div>`\n```",
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: link" {
|
||||
// OSC 8 hyperlink around the label, plus a dim fallback url.
|
||||
try expectRender(
|
||||
"\x1b]8;;https://x.io\x1b\\\x1b[4mLP\x1b[0m\x1b]8;;\x1b\\ \x1b[2m(https://x.io)\x1b[0m",
|
||||
"[LP](https://x.io)",
|
||||
);
|
||||
// A bare link (label == url) omits the redundant suffix.
|
||||
try expectRender(
|
||||
"\x1b]8;;https://x.io\x1b\\\x1b[4mhttps://x.io\x1b[0m\x1b]8;;\x1b\\",
|
||||
"[https://x.io](https://x.io)",
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: blockquote" {
|
||||
try expectRender("\x1b[2m│\x1b[0m quoted \x1b[1mnote\x1b[0m", "> quoted **note**");
|
||||
}
|
||||
|
||||
test "md_term: horizontal rule" {
|
||||
try expectRender("\x1b[2m" ++ "┄" ** 24 ++ "\x1b[0m", "---");
|
||||
try expectRender("\x1b[2m" ++ "┄" ** 24 ++ "\x1b[0m", "***");
|
||||
try expectRender("---x", "---x");
|
||||
}
|
||||
|
||||
test "md_term: tables align columns and style cells" {
|
||||
const B = "\x1b[1m";
|
||||
const C = "\x1b[36m";
|
||||
const D = "\x1b[2m";
|
||||
const R = "\x1b[0m";
|
||||
const pipe = D ++ "│" ++ R;
|
||||
|
||||
// Source is already padded; rendering keeps the alignment after
|
||||
// stripping the cell markers.
|
||||
try expectRender(
|
||||
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++
|
||||
D ++ "├──────┼─────┤" ++ R ++ "\n" ++
|
||||
pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n",
|
||||
"| Tool | Use |\n|--------|-----|\n| `goto` | nav |",
|
||||
);
|
||||
// Gemini-style unpadded source: columns are padded to the widest
|
||||
// rendered cell.
|
||||
try expectRender(
|
||||
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Usage" ++ R ++ " " ++ pipe ++ "\n" ++
|
||||
D ++ "├──────┼─────────────┤" ++ R ++ "\n" ++
|
||||
pipe ++ " goto " ++ pipe ++ " " ++ B ++ "Nav:" ++ R ++ " to url " ++ pipe ++ "\n",
|
||||
"| Tool | Usage |\n| :--- | :--- |\n| goto | **Nav:** to url |",
|
||||
);
|
||||
// A pipe line without a separator row underneath is not a table.
|
||||
try expectRender("| just \x1b[1mtext\x1b[0m |", "| just **text** |");
|
||||
}
|
||||
|
||||
test "md_term: overwide table falls back to verbatim rows" {
|
||||
const header = "|a" ** 17 ++ "|";
|
||||
const sep = "|-" ** 17 ++ "|";
|
||||
try expectRender(
|
||||
"\x1b[1m" ++ header ++ "\x1b[0m\n\x1b[2m" ++ sep ++ "\x1b[0m\n",
|
||||
header ++ "\n" ++ sep,
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: stream renders across chunk boundaries" {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{};
|
||||
try s.feed(&aw.writer, "say **h");
|
||||
try s.feed(&aw.writer, "i** now\n- ite");
|
||||
try s.feed(&aw.writer, "m\ntail");
|
||||
try s.close(&aw.writer);
|
||||
try testing.expectEqualStrings(
|
||||
"say \x1b[1mhi\x1b[0m now\n\x1b[2m•\x1b[0m item\ntail",
|
||||
aw.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: stream withholds tables and renders them aligned" {
|
||||
const B = "\x1b[1m";
|
||||
const C = "\x1b[36m";
|
||||
const D = "\x1b[2m";
|
||||
const R = "\x1b[0m";
|
||||
const pipe = D ++ "│" ++ R;
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{ .show_table_placeholder = true };
|
||||
try s.feed(&aw.writer, "| Tool | Use |\n| :--- | :-");
|
||||
try testing.expectEqualStrings("", aw.written());
|
||||
try s.feed(&aw.writer, "-- |\n| `goto` | nav |\ndone\n");
|
||||
try testing.expectEqualStrings(
|
||||
D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++
|
||||
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++
|
||||
D ++ "├──────┼─────┤" ++ R ++ "\n" ++
|
||||
pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n" ++
|
||||
"done\n",
|
||||
aw.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: stream table ending at close adopts the partial row" {
|
||||
const B = "\x1b[1m";
|
||||
const D = "\x1b[2m";
|
||||
const R = "\x1b[0m";
|
||||
const pipe = D ++ "│" ++ R;
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{ .show_table_placeholder = true };
|
||||
try s.feed(&aw.writer, "| A | B |\n|-|-|\n| x | y |");
|
||||
try s.close(&aw.writer);
|
||||
try testing.expectEqualStrings(
|
||||
D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++
|
||||
pipe ++ " " ++ B ++ "A" ++ R ++ " " ++ pipe ++ " " ++ B ++ "B" ++ R ++ " " ++ pipe ++ "\n" ++
|
||||
D ++ "├───┼───┤" ++ R ++ "\n" ++
|
||||
pipe ++ " x " ++ pipe ++ " y " ++ pipe ++ "\n",
|
||||
aw.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: stream releases a lone pipe row" {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{};
|
||||
try s.feed(&aw.writer, "| a |\nplain\n");
|
||||
try s.close(&aw.writer);
|
||||
try testing.expectEqualStrings("| a |\nplain\n", aw.written());
|
||||
}
|
||||
|
||||
test "md_term: stream fence state spans chunks" {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{};
|
||||
try s.feed(&aw.writer, "```\ncode\n``");
|
||||
try s.feed(&aw.writer, "`\nafter\n");
|
||||
try s.close(&aw.writer);
|
||||
try testing.expectEqualStrings(
|
||||
open_rule ++ "\n\ncode\n\n" ++ close_rule ++ "\nafter\n",
|
||||
aw.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: stream closing fence at message end still draws its rule" {
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
var s: Stream = .{};
|
||||
try s.feed(&aw.writer, "```\ncode\n```");
|
||||
try s.close(&aw.writer);
|
||||
try testing.expectEqualStrings(
|
||||
open_rule ++ "\n\ncode\n\n" ++ close_rule,
|
||||
aw.written(),
|
||||
);
|
||||
}
|
||||
|
||||
test "md_term: backslash escapes" {
|
||||
try expectRender("a * b", "a \\* b");
|
||||
try expectRender("keep \\d and C:\\Users", "keep \\d and C:\\Users");
|
||||
}
|
||||
|
||||
test "md_term: unterminated markers stay literal" {
|
||||
try expectRender("say **hi now", "say **hi now");
|
||||
try expectRender("a * b", "a * b");
|
||||
try expectRender("see [x](y", "see [x](y");
|
||||
}
|
||||
262
src/agent/picker.zig
Normal file
262
src/agent/picker.zig
Normal file
@@ -0,0 +1,262 @@
|
||||
// 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/>.
|
||||
|
||||
//! Interactive numbered-choice picker for stdin/stderr prompts (provider
|
||||
//! selection, /save mode, …). Self-contained raw-terminal handling; runs
|
||||
//! before — or without — the isocline REPL.
|
||||
|
||||
const std = @import("std");
|
||||
const ansi = @import("ansi.zig");
|
||||
|
||||
pub fn interactiveTty() bool {
|
||||
return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO);
|
||||
}
|
||||
|
||||
/// Numbered TTY picker. `default` (if set) marks that row "(default)" and
|
||||
/// makes Enter start on that index. Up/Down moves the active row; Enter
|
||||
/// selects it. Numbered input still works for users who prefer typing.
|
||||
pub fn promptNumberedChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
|
||||
if (items.len == 0) return error.NoChoice;
|
||||
const valid_default: ?usize = if (default) |d| if (d < items.len) d else null else null;
|
||||
if (interactiveTty()) {
|
||||
return promptInteractiveChoice(header, items, valid_default) catch |err| switch (err) {
|
||||
error.NotInteractive => try promptNumberedChoiceLine(header, items, valid_default),
|
||||
else => err,
|
||||
};
|
||||
}
|
||||
return promptNumberedChoiceLine(header, items, valid_default);
|
||||
}
|
||||
|
||||
/// Line-oriented fallback. Errors with NoChoice after 3 invalid attempts.
|
||||
fn promptNumberedChoiceLine(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
|
||||
var stdin_buf: [128]u8 = undefined;
|
||||
var stdin = std.fs.File.stdin().reader(&stdin_buf);
|
||||
|
||||
var attempt: u8 = 0;
|
||||
while (attempt < 3) : (attempt += 1) {
|
||||
std.debug.print("{s}\n", .{header});
|
||||
for (items, 0..) |item, idx| {
|
||||
const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
|
||||
std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker });
|
||||
}
|
||||
std.debug.print("> ", .{});
|
||||
|
||||
const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
|
||||
error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled,
|
||||
};
|
||||
const trimmed = std.mem.trim(u8, line, " \t\r\n");
|
||||
if (trimmed.len == 0) {
|
||||
if (default) |d| return d;
|
||||
std.debug.print("Invalid input — type a number.\n", .{});
|
||||
continue;
|
||||
}
|
||||
const choice = std.fmt.parseInt(usize, trimmed, 10) catch {
|
||||
const hint: []const u8 = if (default != null) " (or press Enter for default)" else "";
|
||||
std.debug.print("Invalid input — type a number{s}.\n", .{hint});
|
||||
continue;
|
||||
};
|
||||
if (choice >= 1 and choice <= items.len) return choice - 1;
|
||||
std.debug.print("Out of range.\n", .{});
|
||||
}
|
||||
return error.NoChoice;
|
||||
}
|
||||
|
||||
const ChoiceInput = enum { up, down, enter, cancel, ignore };
|
||||
|
||||
const ChoiceState = struct {
|
||||
selected: usize,
|
||||
|
||||
fn init(default: ?usize) ChoiceState {
|
||||
return .{ .selected = default orelse 0 };
|
||||
}
|
||||
|
||||
fn apply(self: *ChoiceState, input: ChoiceInput, item_count: usize) ?usize {
|
||||
switch (input) {
|
||||
.up => self.selected = if (self.selected == 0) item_count - 1 else self.selected - 1,
|
||||
.down => self.selected = (self.selected + 1) % item_count,
|
||||
.enter => return self.selected,
|
||||
.cancel, .ignore => {},
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const RawTerminal = struct {
|
||||
original: std.posix.termios,
|
||||
|
||||
fn enable() error{NotInteractive}!RawTerminal {
|
||||
if (!interactiveTty()) return error.NotInteractive;
|
||||
// A tty that refuses raw mode is non-interactive for our purposes.
|
||||
const original = std.posix.tcgetattr(std.posix.STDIN_FILENO) catch return error.NotInteractive;
|
||||
var raw = original;
|
||||
raw.iflag.BRKINT = false;
|
||||
raw.iflag.ICRNL = false;
|
||||
raw.iflag.INPCK = false;
|
||||
raw.iflag.ISTRIP = false;
|
||||
raw.iflag.IXON = false;
|
||||
raw.oflag.OPOST = false;
|
||||
raw.cflag.CSIZE = .CS8;
|
||||
raw.lflag.ECHO = false;
|
||||
raw.lflag.ICANON = false;
|
||||
raw.lflag.IEXTEN = false;
|
||||
raw.lflag.ISIG = false;
|
||||
raw.cc[@intFromEnum(std.c.V.MIN)] = 0;
|
||||
raw.cc[@intFromEnum(std.c.V.TIME)] = 1;
|
||||
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, raw) catch return error.NotInteractive;
|
||||
// Under `ansi.kitty_disambiguate` (pushed by `Terminal.readLine`),
|
||||
// cursor keys arrive as CSI-u the byte reader can't parse; push the
|
||||
// legacy encoding to force plain arrows. restore() pops back to
|
||||
// whatever the REPL had pushed.
|
||||
_ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_legacy) catch {};
|
||||
return .{ .original = original };
|
||||
}
|
||||
|
||||
fn restore(self: *const RawTerminal) void {
|
||||
_ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_pop) catch {};
|
||||
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, self.original) catch {};
|
||||
}
|
||||
};
|
||||
|
||||
fn promptInteractiveChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
|
||||
var raw: RawTerminal = try .enable();
|
||||
defer raw.restore();
|
||||
|
||||
var state: ChoiceState = .init(default);
|
||||
const line_count = items.len + 2;
|
||||
var first_render = true;
|
||||
while (true) {
|
||||
renderChoice(header, items, default, state.selected, first_render);
|
||||
first_render = false;
|
||||
|
||||
const input = readChoiceInput() catch return error.UserCancelled;
|
||||
if (input == .cancel) {
|
||||
clearChoiceRender(line_count);
|
||||
return error.UserCancelled;
|
||||
}
|
||||
if (state.apply(input, items.len)) |idx| {
|
||||
clearChoiceRender(line_count);
|
||||
std.debug.print("{s} {s}\r\n", .{ header, items[idx] });
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a whole redraw frame in one write — per-line writes can tear
|
||||
/// visually mid-frame (same approach as Spinner's renderLocked). A frame
|
||||
/// that outgrows the buffer is emitted truncated.
|
||||
fn emitFrame(bytes: []const u8) void {
|
||||
std.debug.lockStdErr();
|
||||
defer std.debug.unlockStdErr();
|
||||
_ = std.posix.write(std.posix.STDERR_FILENO, bytes) catch {};
|
||||
}
|
||||
|
||||
const frame_buf_len = 4096;
|
||||
|
||||
fn clearChoiceRender(line_count: usize) void {
|
||||
var buf: [frame_buf_len]u8 = undefined;
|
||||
var fw: std.Io.Writer = .fixed(&buf);
|
||||
blk: {
|
||||
moveChoiceRenderStart(&fw, line_count) catch break :blk;
|
||||
for (0..line_count) |i| {
|
||||
fw.writeAll(ansi.clear_line) catch break :blk;
|
||||
if (i + 1 < line_count) fw.writeAll("\r\n") catch break :blk;
|
||||
}
|
||||
moveChoiceRenderStart(&fw, line_count) catch break :blk;
|
||||
}
|
||||
emitFrame(fw.buffered());
|
||||
}
|
||||
|
||||
fn moveChoiceRenderStart(w: *std.Io.Writer, line_count: usize) std.Io.Writer.Error!void {
|
||||
if (line_count > 1) {
|
||||
try w.print("\x1b[{d}F", .{line_count - 1});
|
||||
} else {
|
||||
try w.writeAll("\r");
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChoice(header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) void {
|
||||
var buf: [frame_buf_len]u8 = undefined;
|
||||
var fw: std.Io.Writer = .fixed(&buf);
|
||||
writeChoiceFrame(&fw, header, items, default, selected, first_render) catch {};
|
||||
emitFrame(fw.buffered());
|
||||
}
|
||||
|
||||
fn writeChoiceFrame(w: *std.Io.Writer, header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) std.Io.Writer.Error!void {
|
||||
if (!first_render) try moveChoiceRenderStart(w, items.len + 2);
|
||||
try w.print(ansi.clear_line ++ "{s}\r\n", .{header});
|
||||
for (items, 0..) |item, idx| {
|
||||
const on_row = idx == selected;
|
||||
const marker: []const u8 = if (on_row) ">" else " ";
|
||||
const style: []const u8 = if (on_row) ansi.bold ++ ansi.teal else "";
|
||||
const reset: []const u8 = if (on_row) ansi.reset else "";
|
||||
const default_marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
|
||||
try w.print(ansi.clear_line ++ " {s} {s}{s}{s}{s}\r\n", .{ marker, style, item, default_marker, reset });
|
||||
}
|
||||
try w.print(ansi.clear_line ++ "{s}Use Up/Down then Enter. Esc cancels.{s}", .{ ansi.dim, ansi.reset });
|
||||
}
|
||||
|
||||
fn readChoiceInput() !ChoiceInput {
|
||||
while (true) {
|
||||
const ch = try readChoiceByte() orelse continue;
|
||||
return switch (ch) {
|
||||
3, 4, 27 => esc: {
|
||||
if (ch != 27) break :esc .cancel;
|
||||
const b1 = try readChoiceByte() orelse break :esc .cancel;
|
||||
if (b1 != '[' and b1 != 'O') break :esc .cancel;
|
||||
const b2 = try readChoiceByte() orelse break :esc .cancel;
|
||||
break :esc switch (b2) {
|
||||
'A' => .up,
|
||||
'B' => .down,
|
||||
else => .ignore,
|
||||
};
|
||||
},
|
||||
'\r', '\n' => .enter,
|
||||
else => .ignore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn readChoiceByte() !?u8 {
|
||||
var buf: [1]u8 = undefined;
|
||||
const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| switch (err) {
|
||||
error.WouldBlock => return null,
|
||||
error.InputOutput => return error.ReadFailed,
|
||||
else => return err,
|
||||
};
|
||||
if (n == 0) return null;
|
||||
return buf[0];
|
||||
}
|
||||
|
||||
test "ChoiceState: arrows wrap and enter selects highlighted item" {
|
||||
var state: ChoiceState = .init(null);
|
||||
try std.testing.expectEqual(@as(usize, 0), state.selected);
|
||||
|
||||
try std.testing.expectEqual(@as(?usize, null), state.apply(.up, 3));
|
||||
try std.testing.expectEqual(@as(usize, 2), state.selected);
|
||||
|
||||
try std.testing.expectEqual(@as(?usize, null), state.apply(.down, 3));
|
||||
try std.testing.expectEqual(@as(usize, 0), state.selected);
|
||||
|
||||
try std.testing.expectEqual(@as(?usize, 0), state.apply(.enter, 3));
|
||||
}
|
||||
|
||||
test "ChoiceState: starts on default and enter returns it" {
|
||||
var state: ChoiceState = .init(2);
|
||||
try std.testing.expectEqual(@as(usize, 2), state.selected);
|
||||
try std.testing.expectEqual(@as(?usize, 2), state.apply(.enter, 3));
|
||||
}
|
||||
840
src/agent/prompt_assist.zig
Normal file
840
src/agent/prompt_assist.zig
Normal file
@@ -0,0 +1,840 @@
|
||||
// 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/>.
|
||||
|
||||
//! The REPL prompt's input assistance: isocline completion, ghost hints, and
|
||||
//! syntax highlighting over the slash-command schemas and JS mode. Terminal
|
||||
//! owns the readline lifecycle; this module owns everything that reacts to
|
||||
//! the buffer while the user types.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const browser_tools = lp.tools;
|
||||
const Schema = lp.Schema;
|
||||
const SlashCommand = @import("SlashCommand.zig");
|
||||
const js_highlight = @import("js_highlight.zig");
|
||||
const c = @cImport({
|
||||
@cInclude("isocline.h");
|
||||
});
|
||||
|
||||
const style_slash = "ps-slash";
|
||||
const style_string = "ps-string";
|
||||
const style_var = "ps-var";
|
||||
const style_url = "ps-url";
|
||||
const style_key = "ps-key";
|
||||
const style_num = "ps-num";
|
||||
const style_err = "ps-err";
|
||||
const style_jsmode = "ps-jsmode";
|
||||
|
||||
/// The prompt's style palette (`ps-*` avoids isocline's built-in `ic-*`
|
||||
/// namespace). Registration and token-kind painting both derive from it, so
|
||||
/// a painted style is registered by construction.
|
||||
const Style = struct {
|
||||
name: [:0]const u8,
|
||||
spec: [:0]const u8,
|
||||
/// `js_highlight` token kinds painted with this style; empty for styles
|
||||
/// applied directly by name.
|
||||
kinds: []const js_highlight.Kind = &.{},
|
||||
};
|
||||
|
||||
const styles = [_]Style{
|
||||
.{ .name = style_slash, .spec = "ansi-teal bold" },
|
||||
.{ .name = style_string, .spec = "ansi-green", .kinds = &.{.string} },
|
||||
.{ .name = style_var, .spec = "ansi-yellow", .kinds = &.{ .variable, .interpolation } },
|
||||
.{ .name = style_url, .spec = "ansi-blue underline" },
|
||||
.{ .name = style_key, .spec = "ansi-blue" },
|
||||
.{ .name = style_num, .spec = "ansi-magenta", .kinds = &.{.number} },
|
||||
.{ .name = style_err, .spec = "ansi-red" },
|
||||
.{ .name = style_jsmode, .spec = "ansi-red bold" },
|
||||
.{ .name = "ps-keyword", .spec = "ansi-blue bold", .kinds = &.{.keyword} },
|
||||
.{ .name = "ps-comment", .spec = "ansi-darkgray italic", .kinds = &.{.comment} },
|
||||
.{ .name = "ps-jsglobal", .spec = "ansi-cyan", .kinds = &.{.global} },
|
||||
.{ .name = "ps-fn", .spec = "ansi-teal", .kinds = &.{.function} },
|
||||
.{ .name = "ps-method", .spec = "ansi-teal italic", .kinds = &.{.method} },
|
||||
.{ .name = "ps-type", .spec = "ansi-cyan", .kinds = &.{.type_name} },
|
||||
};
|
||||
|
||||
/// Style name per token kind, derived from `styles`. Unmapped or
|
||||
/// doubly-mapped kinds are compile errors.
|
||||
const kind_styles = blk: {
|
||||
const n = std.enums.values(js_highlight.Kind).len;
|
||||
var arr: [n]?[:0]const u8 = @splat(null);
|
||||
for (styles) |s| for (s.kinds) |kind| {
|
||||
if (arr[@intFromEnum(kind)] != null) @compileError("kind styled twice: " ++ @tagName(kind));
|
||||
arr[@intFromEnum(kind)] = s.name;
|
||||
};
|
||||
var out: [n][:0]const u8 = undefined;
|
||||
for (arr, 0..) |name, i| {
|
||||
out[i] = name orelse @compileError("js_highlight.Kind with no ps-* style: " ++
|
||||
@tagName(@as(js_highlight.Kind, @enumFromInt(i))));
|
||||
}
|
||||
break :blk out;
|
||||
};
|
||||
|
||||
/// Lets the completer/hinter pull dynamic candidates from the `Agent` without
|
||||
/// this module depending on it (same idiom as `Session.cancel_hook`).
|
||||
pub const CompletionSource = struct {
|
||||
context: *anyopaque,
|
||||
providers: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8,
|
||||
/// May block on an HTTP fetch.
|
||||
models: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8,
|
||||
};
|
||||
|
||||
/// Separate history files for normal and JS prompt modes. isocline holds one
|
||||
/// history list at a time, so we swap files on mode toggle rather than tag a
|
||||
/// shared file.
|
||||
pub const HistoryPaths = struct {
|
||||
normal: [:0]const u8,
|
||||
js: [:0]const u8,
|
||||
};
|
||||
|
||||
/// The mutable state the C callbacks read: registered once via `attach`, so
|
||||
/// it must live at a stable address.
|
||||
pub const State = struct {
|
||||
/// True while the REPL is in JS mode; set by isocline's mode callback.
|
||||
js_mode: bool = false,
|
||||
completion_source: ?CompletionSource = null,
|
||||
/// Per-mode history files (null outside REPL mode). `modeCallback` swaps
|
||||
/// the active one so JS and normal recall stay separate.
|
||||
history_paths: ?HistoryPaths = null,
|
||||
};
|
||||
|
||||
/// One-time isocline configuration for REPL mode. Probes the terminal
|
||||
/// (isocline writes an ESC[6n cursor-report on stdout), so callers must skip
|
||||
/// it in script-only mode.
|
||||
pub fn setupRepl() void {
|
||||
_ = c.ic_enable_multiline(true);
|
||||
_ = c.ic_enable_hint(true);
|
||||
_ = c.ic_enable_inline_help(true);
|
||||
// Show ghost completions instantly; isocline's default is 400 ms.
|
||||
_ = c.ic_set_hint_delay(0);
|
||||
_ = c.ic_enable_brace_insertion(true);
|
||||
for (styles) |s| c.ic_style_def(s.name.ptr, s.spec.ptr);
|
||||
// lighten the ghost/inline-hint color from isocline's default ansi-darkgray
|
||||
c.ic_style_def("ic-hint", "ansi-color=244");
|
||||
// `!` on an empty prompt toggles JS mode; state callback wired in attach.
|
||||
c.ic_set_prompt_mode("[" ++ style_jsmode ++ "]![/" ++ style_jsmode ++ "] ", '!');
|
||||
// Blank continuation marker so multiline input isn't prefixed with `>`.
|
||||
c.ic_set_prompt_marker("❯ ", "");
|
||||
_ = c.ic_enable_highlight(true);
|
||||
}
|
||||
|
||||
/// Wires the isocline completer, hinter, and highlighter to `state` and
|
||||
/// loads the initial history. Must run after `state` is in its final memory
|
||||
/// location and before the first readline.
|
||||
pub fn attach(state: *State) void {
|
||||
c.ic_set_default_completer(&completionCallback, state);
|
||||
c.ic_set_default_hinter(&hintsCallback, state);
|
||||
c.ic_set_mode_callback(&modeCallback, state);
|
||||
c.ic_set_ctrl_d_hint(" press Ctrl-D again to exit");
|
||||
c.ic_set_esc_clear_hint(" esc again to clear");
|
||||
c.ic_set_mode_hint(" JS mode - esc to exit");
|
||||
c.ic_set_default_highlighter(&highlighterCallback, state);
|
||||
if (state.history_paths) |hp| {
|
||||
// Mode inactive at launch, so load the normal file; modeCallback
|
||||
// swaps to JS on mode entry.
|
||||
c.ic_set_history(hp.normal.ptr, -1); // -1 → 200-entry default cap
|
||||
}
|
||||
}
|
||||
|
||||
fn modeCallback(active: bool, arg: ?*anyopaque) callconv(.c) void {
|
||||
const state: *State = @ptrCast(@alignCast(arg orelse return));
|
||||
state.js_mode = active;
|
||||
if (state.history_paths) |hp| {
|
||||
c.ic_set_history((if (active) hp.js else hp.normal).ptr, -1);
|
||||
}
|
||||
}
|
||||
|
||||
const completion_buf_len = 512;
|
||||
|
||||
fn addPrefixedCompletion(
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
input: []const u8,
|
||||
prefix: []const u8,
|
||||
name: []const u8,
|
||||
suffix: []const u8,
|
||||
partial: []const u8,
|
||||
) void {
|
||||
if (!std.ascii.startsWithIgnoreCase(name, partial)) return;
|
||||
const text = std.fmt.bufPrintZ(buf, "{s}{s}{s}", .{ prefix, name, suffix }) catch return;
|
||||
_ = c.ic_add_completion_prim(cenv, text.ptr, null, null, @intCast(input.len), 0);
|
||||
}
|
||||
|
||||
// Cap on tokens read out of the body; extra tokens are ignored. Real schemas
|
||||
// and CLI inputs have far fewer fields.
|
||||
const max_tokens = 32;
|
||||
|
||||
const BodyAnalysis = struct {
|
||||
used: [max_tokens][]const u8 = undefined,
|
||||
used_len: usize = 0,
|
||||
// Trailing in-progress token when the user is typing a key prefix (no `=`
|
||||
// yet, not a positional binding). Null when the body is empty, ends with
|
||||
// whitespace, or the trailing token is fully committed.
|
||||
partial_key: ?[]const u8 = null,
|
||||
|
||||
fn markUsed(self: *BodyAnalysis, name: []const u8) void {
|
||||
if (self.used_len >= self.used.len) return;
|
||||
self.used[self.used_len] = name;
|
||||
self.used_len += 1;
|
||||
}
|
||||
|
||||
fn isUsed(self: *const BodyAnalysis, name: []const u8) bool {
|
||||
for (self.used[0..self.used_len]) |u| {
|
||||
if (std.mem.eql(u8, u, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
fn analyzeBody(schema: *const Schema, body: []const u8, ends_ws: bool) BodyAnalysis {
|
||||
var a: BodyAnalysis = .{};
|
||||
|
||||
var tokens: [max_tokens][]const u8 = undefined;
|
||||
var n: usize = 0;
|
||||
var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace);
|
||||
while (it.next()) |tok| {
|
||||
if (n >= tokens.len) break;
|
||||
tokens[n] = tok;
|
||||
n += 1;
|
||||
}
|
||||
if (n == 0) return a;
|
||||
|
||||
const last = n - 1;
|
||||
for (tokens[0..n], 0..) |tok, i| {
|
||||
if (std.mem.indexOfScalar(u8, tok, '=')) |eq| {
|
||||
a.markUsed(tok[0..eq]);
|
||||
continue;
|
||||
}
|
||||
// The first bare arg binds positionally to the schema's positional
|
||||
// field (`/goto https://example.com`, `/getEnv LP_TOKEN`).
|
||||
if (i == 0) if (schema.leadingPositionalField()) |pos| {
|
||||
a.markUsed(pos);
|
||||
continue;
|
||||
};
|
||||
if (i == last and !ends_ws) a.partial_key = tok;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const help_arg_prefix = "/help ";
|
||||
|
||||
fn parseHelpArgPrefix(input: []const u8) ?[]const u8 {
|
||||
if (!std.ascii.startsWithIgnoreCase(input, help_arg_prefix)) return null;
|
||||
const arg = std.mem.trimLeft(u8, input[help_arg_prefix.len..], " ");
|
||||
if (std.mem.indexOfScalar(u8, arg, ' ') != null) return null;
|
||||
return arg;
|
||||
}
|
||||
|
||||
/// A field whose value the cursor is positioned to complete, plus the partial
|
||||
/// value typed so far. Covers both the leading positional (`/waitForState net`)
|
||||
/// and an explicit `key=` pair (`/waitForState state=net`).
|
||||
const ValueAt = struct {
|
||||
field: Schema.FieldEntry,
|
||||
partial: []const u8,
|
||||
/// `key=value` form rather than the bare leading positional.
|
||||
kv: bool,
|
||||
};
|
||||
|
||||
/// Classifies the token under the cursor as a value position for some schema
|
||||
/// field. Null when the cursor isn't on a completable value (a key prefix, a
|
||||
/// non-leading positional, or an unknown field).
|
||||
fn valueAt(schema: *const Schema, body: []const u8, ends_ws: bool) ?ValueAt {
|
||||
var last: []const u8 = "";
|
||||
var n: usize = 0;
|
||||
var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace);
|
||||
while (it.next()) |tok| {
|
||||
last = tok;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
// An empty body or a trailing space puts the cursor on a fresh token after
|
||||
// the `n` committed ones; otherwise it sits on the last token.
|
||||
const active: []const u8 = if (ends_ws or n == 0) "" else last;
|
||||
const active_index: usize = if (ends_ws or n == 0) n else n - 1;
|
||||
|
||||
if (std.mem.indexOfScalar(u8, active, '=')) |eq| {
|
||||
const field = schema.findField(active[0..eq]) orelse return null;
|
||||
return .{ .field = field, .partial = active[eq + 1 ..], .kv = true };
|
||||
}
|
||||
// The leading bare token binds to the schema's positional field (lone
|
||||
// required, or sole optional field like getEnv's `name`).
|
||||
if (active_index == 0) if (schema.leadingPositionalField()) |pos| {
|
||||
const field = schema.findField(pos) orelse return null;
|
||||
return .{ .field = field, .partial = active, .kv = false };
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns true when it owns the completion, so the caller skips key hints.
|
||||
fn addValueCompletions(
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
input: []const u8,
|
||||
body: []const u8,
|
||||
schema: *const Schema,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
) bool {
|
||||
const ends_ws = input[input.len - 1] == ' ';
|
||||
const v = valueAt(schema, body, ends_ws) orelse return false;
|
||||
const prefix = input[0 .. input.len - v.partial.len];
|
||||
if (schema.tool == .getEnv) {
|
||||
var name_buf: [2048]u8 = undefined;
|
||||
const names = lpEnvNameList(&name_buf) orelse return true;
|
||||
for (names) |name| addPrefixedCompletion(cenv, buf, input, prefix, name, "", v.partial);
|
||||
return true;
|
||||
}
|
||||
if (v.field.enum_values.len == 0) return false;
|
||||
for (v.field.enum_values) |val| addPrefixedCompletion(cenv, buf, input, prefix, val, "", v.partial);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn addPartialKeyCompletions(
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
input: []const u8,
|
||||
body: []const u8,
|
||||
schema: *const Schema,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
) void {
|
||||
std.debug.assert(input.len > 0);
|
||||
const ends_ws = input[input.len - 1] == ' ';
|
||||
const a = analyzeBody(schema, body, ends_ws);
|
||||
// Without a partial AND without trailing whitespace, the user is mid-typing
|
||||
// a positional value or some other non-completable state — bail.
|
||||
if (a.partial_key == null and !ends_ws) return;
|
||||
|
||||
const partial = a.partial_key orelse "";
|
||||
const prefix = input[0 .. input.len - partial.len];
|
||||
for (schema.hints) |slot| {
|
||||
if (a.isUsed(slot.name)) continue;
|
||||
addPrefixedCompletion(cenv, buf, input, prefix, slot.name, "=", partial);
|
||||
}
|
||||
}
|
||||
|
||||
fn addMetaValueCompletions(
|
||||
state: *State,
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
input: []const u8,
|
||||
body: []const u8,
|
||||
meta: *const SlashCommand.MetaCommand,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
) void {
|
||||
// Past the first positional arg — don't offer value completions anymore.
|
||||
if (std.mem.indexOfAny(u8, body, &std.ascii.whitespace) != null) return;
|
||||
const prefix = input[0 .. input.len - body.len];
|
||||
|
||||
if (meta.tag == .load or meta.tag == .save) {
|
||||
addPathCompletions(cenv, input, body, prefix, buf);
|
||||
return;
|
||||
}
|
||||
|
||||
// `/provider` / `/model` candidates are resolved at runtime, not in `meta.values`.
|
||||
if (state.completion_source) |src| switch (meta.tag) {
|
||||
.provider, .model => {
|
||||
var name_buf: [512]u8 = undefined;
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&name_buf);
|
||||
const names = if (meta.tag == .provider)
|
||||
src.providers(src.context, fba.allocator())
|
||||
else
|
||||
src.models(src.context, fba.allocator());
|
||||
for (names) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body);
|
||||
return;
|
||||
},
|
||||
else => {},
|
||||
};
|
||||
|
||||
for (meta.values) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body);
|
||||
}
|
||||
|
||||
/// Directory entries whose basename completes the partial path `body`
|
||||
/// (`dir/ba` → entries of `dir/` prefix-matching `ba`). Returned names
|
||||
/// borrow the iterator; use before the next `next()`.
|
||||
const PathMatchIterator = struct {
|
||||
dir: std.fs.Dir,
|
||||
it: std.fs.Dir.Iterator,
|
||||
/// `body` up to and including its last `/`; kept verbatim in candidates.
|
||||
dir_part: []const u8,
|
||||
base: []const u8,
|
||||
|
||||
const Match = struct { name: []const u8, is_dir: bool };
|
||||
|
||||
fn init(body: []const u8) ?PathMatchIterator {
|
||||
const slash = std.mem.lastIndexOfScalar(u8, body, '/');
|
||||
const dir_part = if (slash) |i| body[0 .. i + 1] else "";
|
||||
const open_path = if (dir_part.len == 0) "." else dir_part;
|
||||
const dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return null;
|
||||
return .{
|
||||
.dir = dir,
|
||||
.it = dir.iterate(),
|
||||
.dir_part = dir_part,
|
||||
.base = body[dir_part.len..],
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *PathMatchIterator) void {
|
||||
self.dir.close();
|
||||
}
|
||||
|
||||
fn next(self: *PathMatchIterator) ?Match {
|
||||
while (self.it.next() catch return null) |entry| {
|
||||
if (!std.ascii.startsWithIgnoreCase(entry.name, self.base)) continue;
|
||||
return .{ .name = entry.name, .is_dir = entry.kind == .directory };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
fn addPathCompletions(
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
input: []const u8,
|
||||
body: []const u8,
|
||||
prefix: []const u8,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
) void {
|
||||
var matches = PathMatchIterator.init(body) orelse return;
|
||||
defer matches.deinit();
|
||||
|
||||
var name_buf: [completion_buf_len]u8 = undefined;
|
||||
while (matches.next()) |m| {
|
||||
const suffix: []const u8 = if (m.is_dir) "/" else "";
|
||||
const full = std.fmt.bufPrint(&name_buf, "{s}{s}", .{ matches.dir_part, m.name }) catch continue;
|
||||
addPrefixedCompletion(cenv, buf, input, prefix, full, suffix, body);
|
||||
}
|
||||
}
|
||||
|
||||
/// LP_* env var names (sorted) written into `buf`; null on enumeration failure.
|
||||
/// Returned slices borrow `buf`, which must outlive them.
|
||||
fn lpEnvNameList(buf: []u8) ?[]const []const u8 {
|
||||
var fba: std.heap.FixedBufferAllocator = .init(buf);
|
||||
return browser_tools.lpEnvNames(fba.allocator()) catch null;
|
||||
}
|
||||
|
||||
/// Completes `$LP_*` against the live process environment.
|
||||
fn addEnvVarCompletions(
|
||||
cenv: ?*c.ic_completion_env_t,
|
||||
buf: *[completion_buf_len:0]u8,
|
||||
input: []const u8,
|
||||
) void {
|
||||
const dollar = std.mem.lastIndexOfScalar(u8, input, '$') orelse return;
|
||||
const partial = input[dollar + 1 ..];
|
||||
for (partial) |ch| {
|
||||
if (!std.ascii.isAlphanumeric(ch) and ch != '_') return;
|
||||
}
|
||||
|
||||
var name_buf: [2048]u8 = undefined;
|
||||
const names = lpEnvNameList(&name_buf) orelse return;
|
||||
if (names.len == 0) return;
|
||||
|
||||
const head = input[0 .. dollar + 1];
|
||||
for (names) |name| addPrefixedCompletion(cenv, buf, input, head, name, "", partial);
|
||||
}
|
||||
|
||||
fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callconv(.c) void {
|
||||
const state: *State = @ptrCast(@alignCast(c.ic_completion_arg(cenv) orelse return));
|
||||
const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(prefix)), 0);
|
||||
|
||||
var buf: [completion_buf_len:0]u8 = undefined;
|
||||
|
||||
// `/help <name>`: arg is a command name, not a value — skip env-var fallthrough.
|
||||
if (parseHelpArgPrefix(input)) |partial| {
|
||||
for (SlashCommand.all_names) |name| addPrefixedCompletion(cenv, &buf, input, help_arg_prefix, name, "", partial);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.len == 0) return;
|
||||
const has_space = std.mem.indexOfScalar(u8, input, ' ') != null;
|
||||
const inside_block = Schema.hasUnclosedTripleQuote(input);
|
||||
|
||||
if (input[0] == '/') {
|
||||
if (!has_space) {
|
||||
const partial = input[1..];
|
||||
// Trailing space on commands with params hands off to the hinter,
|
||||
// which renders the full ` <url> [timeout=…]` template uniformly
|
||||
// whether the name was typed or Tab-completed.
|
||||
for (SlashCommand.all_names) |name| {
|
||||
const suffix: []const u8 = if (slashHasParams(name)) " " else "";
|
||||
addPrefixedCompletion(cenv, &buf, input, "/", name, suffix, partial);
|
||||
}
|
||||
return;
|
||||
} else if (!inside_block) {
|
||||
if (Schema.parseSlashCommand(input)) |parts| {
|
||||
if (Schema.findByName(parts.name)) |schema| {
|
||||
if (!addValueCompletions(cenv, input, parts.rest, schema, &buf)) {
|
||||
addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf);
|
||||
}
|
||||
} else if (SlashCommand.findMeta(parts.name)) |meta| {
|
||||
addMetaValueCompletions(state, cenv, input, parts.rest, meta, &buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall through so `value=$LP_` picks up env completions, including
|
||||
// inside an unclosed `'''` block.
|
||||
}
|
||||
|
||||
addEnvVarCompletions(cenv, &buf, input);
|
||||
}
|
||||
|
||||
// File-scope so the buffer outlives the callback's stack frame. Isocline's
|
||||
// `sbuf_replace` copies the returned string into its own stringbuf, so
|
||||
// overwriting this on the next invocation is safe. Single-threaded: isocline's
|
||||
// edit loop runs on the main thread, and we have one Terminal instance.
|
||||
var hint_buf: [completion_buf_len:0]u8 = undefined;
|
||||
|
||||
fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const u8 {
|
||||
const state: *State = @ptrCast(@alignCast(arg orelse return null));
|
||||
const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input_c)), 0);
|
||||
|
||||
// JS mode: the buffer is raw JS, so slash/kv hints don't apply.
|
||||
if (state.js_mode) return null;
|
||||
|
||||
if (input.len == 0) return null;
|
||||
|
||||
if (parseHelpArgPrefix(input)) |partial| return ghostFirstMatch(&SlashCommand.all_names, partial, "");
|
||||
|
||||
// Inside an open `'''…'''` body the buffer is script text, not kv args.
|
||||
if (Schema.hasUnclosedTripleQuote(input)) return null;
|
||||
|
||||
if (std.mem.eql(u8, input, "/")) return ghostFirstMatch(&SlashCommand.all_names, "", "");
|
||||
|
||||
if (Schema.parseSlashCommand(input)) |parts| {
|
||||
const ends_ws = input[input.len - 1] == ' ';
|
||||
if (Schema.findByName(parts.name)) |schema| {
|
||||
return renderSchemaHint(schema, parts.rest, ends_ws);
|
||||
}
|
||||
if (SlashCommand.findMeta(parts.name)) |meta| {
|
||||
return renderMetaHint(state, meta, parts.rest, ends_ws);
|
||||
}
|
||||
if (std.mem.indexOfScalar(u8, input, ' ') == null) {
|
||||
return ghostFirstMatch(&SlashCommand.all_names, parts.name, "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-slash lines are natural-language prompts to the LLM (REPL only).
|
||||
// No syntactic hint to render — the LLM sees the line verbatim.
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Join `fragments` into `hint_buf` with single-space separators, prefixed by
|
||||
/// `lead` (typically `""` or `" "`). Null-terminates and returns the isocline
|
||||
/// C pointer, or null when nothing to render or the buffer would overflow.
|
||||
fn writeHints(lead: []const u8, fragments: []const []const u8) [*c]const u8 {
|
||||
if (fragments.len == 0) return null;
|
||||
const cap = hint_buf.len - 1;
|
||||
if (lead.len > cap) return null;
|
||||
@memcpy(hint_buf[0..lead.len], lead);
|
||||
var pos: usize = lead.len;
|
||||
for (fragments, 0..) |frag, i| {
|
||||
if (i > 0) {
|
||||
if (pos + 1 > cap) return null;
|
||||
hint_buf[pos] = ' ';
|
||||
pos += 1;
|
||||
}
|
||||
if (pos + frag.len > cap) return null;
|
||||
@memcpy(hint_buf[pos..][0..frag.len], frag);
|
||||
pos += frag.len;
|
||||
}
|
||||
hint_buf[pos] = 0;
|
||||
return @ptrCast(&hint_buf);
|
||||
}
|
||||
|
||||
/// Ghosts a meta command's argument: providers resolve synchronously, `/model`
|
||||
/// needs a blocking fetch (placeholder until committed), static values match
|
||||
/// `meta.values`.
|
||||
fn renderMetaHint(state: *State, meta: *const SlashCommand.MetaCommand, body: []const u8, ends_ws: bool) [*c]const u8 {
|
||||
if (meta.hint.len == 0) return null;
|
||||
if (ends_ws and body.len != 0) return null; // value already committed
|
||||
|
||||
if (state.completion_source) |src| {
|
||||
var name_buf: [512]u8 = undefined;
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&name_buf);
|
||||
if (meta.tag == .provider) {
|
||||
const lead: []const u8 = if (body.len == 0 and !ends_ws) " " else "";
|
||||
return ghostFirstMatch(src.providers(src.context, fba.allocator()), body, lead);
|
||||
}
|
||||
if (meta.tag == .model and (ends_ws or body.len != 0)) {
|
||||
return ghostFirstMatch(src.models(src.context, fba.allocator()), body, "");
|
||||
}
|
||||
}
|
||||
|
||||
if (body.len == 0) {
|
||||
var frags: [1][]const u8 = .{meta.hint};
|
||||
return writeHints(if (ends_ws) "" else " ", &frags);
|
||||
}
|
||||
if (ends_ws) return null;
|
||||
if (meta.tag == .load or meta.tag == .save) return ghostPathFirstMatch(body);
|
||||
return ghostFirstMatch(meta.values, body, "");
|
||||
}
|
||||
|
||||
fn ghostPathFirstMatch(body: []const u8) [*c]const u8 {
|
||||
var matches = PathMatchIterator.init(body) orelse return null;
|
||||
defer matches.deinit();
|
||||
const m = matches.next() orelse return null;
|
||||
const suffix: []const u8 = if (m.is_dir) "/" else "";
|
||||
const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ m.name[matches.base.len..], suffix }) catch return null;
|
||||
return text.ptr;
|
||||
}
|
||||
|
||||
/// Ghosts `lead` + the suffix of the first `names` entry that prefix-matches
|
||||
/// `body`.
|
||||
fn ghostFirstMatch(names: []const []const u8, body: []const u8, lead: []const u8) [*c]const u8 {
|
||||
for (names) |v| {
|
||||
if (!std.ascii.startsWithIgnoreCase(v, body)) continue;
|
||||
const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ lead, v[body.len..] }) catch return null;
|
||||
return text.ptr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Renders `<required>` and `[optional=…]` for each unused field, or
|
||||
/// `<keyname>=…` when the user is typing a key prefix.
|
||||
fn renderSchemaHint(schema: *const Schema, body: []const u8, ends_ws: bool) [*c]const u8 {
|
||||
// Ghost a matching enum value once the user is typing one. A bare leading
|
||||
// positional with nothing typed keeps the `<state> …` template below — more
|
||||
// informative than ghosting one arbitrary value.
|
||||
if (valueAt(schema, body, ends_ws)) |v| {
|
||||
if (v.field.enum_values.len > 0 and (v.kv or v.partial.len > 0)) {
|
||||
return ghostFirstMatch(v.field.enum_values, v.partial, "");
|
||||
}
|
||||
// getEnv's `name` ghosts a live LP_* var, like /provider ghosts a provider.
|
||||
if (schema.tool == .getEnv) {
|
||||
var name_buf: [2048]u8 = undefined;
|
||||
if (lpEnvNameList(&name_buf)) |names| {
|
||||
const lead: []const u8 = if (v.partial.len == 0 and !ends_ws) " " else "";
|
||||
if (ghostFirstMatch(names, v.partial, lead)) |hint| return hint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const a = analyzeBody(schema, body, ends_ws);
|
||||
|
||||
if (a.partial_key) |pk| {
|
||||
for (schema.hints) |slot| {
|
||||
if (a.isUsed(slot.name)) continue;
|
||||
if (!std.ascii.startsWithIgnoreCase(slot.name, pk)) continue;
|
||||
const text = std.fmt.bufPrintZ(&hint_buf, "{s}=…", .{slot.name[pk.len..]}) catch return null;
|
||||
return text.ptr;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var frags: [Schema.max_hint_slots][]const u8 = undefined;
|
||||
var n: usize = 0;
|
||||
for (schema.hints) |slot| {
|
||||
if (a.isUsed(slot.name)) continue;
|
||||
frags[n] = slot.fragment;
|
||||
n += 1;
|
||||
}
|
||||
return writeHints(if (ends_ws) "" else " ", frags[0..n]);
|
||||
}
|
||||
|
||||
/// Byte offsets to ic_highlight are not UTF-8 code points; safe because we
|
||||
/// only tokenize on ASCII boundaries (whitespace, quotes, `=`, `$`).
|
||||
fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, arg: ?*anyopaque) callconv(.c) void {
|
||||
const state: *State = @ptrCast(@alignCast(arg orelse return));
|
||||
const text = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input)), 0);
|
||||
// JS mode: the buffer is raw JS, so highlight it as such (plus `$LP_*` refs).
|
||||
if (state.js_mode) {
|
||||
highlightJavaScript(henv, text);
|
||||
return;
|
||||
}
|
||||
const cmd_start = std.mem.indexOfNonePos(u8, text, 0, &std.ascii.whitespace) orelse return;
|
||||
var i = cmd_start;
|
||||
while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1;
|
||||
const cmd = text[cmd_start..i];
|
||||
// Commit to red once the cursor moves past the token, OR as soon as the
|
||||
// prefix cannot complete to any known name.
|
||||
const closed = i < text.len;
|
||||
if (cmd.len > 0 and cmd[0] == '/') {
|
||||
c.ic_highlight(henv, @intCast(cmd_start), 1, style_slash.ptr);
|
||||
if (cmd.len > 1) {
|
||||
const name = cmd[1..];
|
||||
const style: ?[*:0]const u8 = if (isKnownSlashName(name))
|
||||
style_slash
|
||||
else if (closed or !slashHasPrefix(name))
|
||||
style_err
|
||||
else
|
||||
null;
|
||||
if (style) |s| c.ic_highlight(henv, @intCast(cmd_start + 1), @intCast(cmd.len - 1), s);
|
||||
}
|
||||
highlightSlashArgs(henv, text, i);
|
||||
} else {
|
||||
// No leading `/`: a natural-language prompt, so no command validation.
|
||||
// Start at `cmd_start`, not `i`, so a `$LP_*` first token highlights too.
|
||||
highlightDollarVars(henv, text, cmd_start);
|
||||
}
|
||||
}
|
||||
|
||||
fn isKnownSlashName(name: []const u8) bool {
|
||||
for (SlashCommand.all_names) |n| {
|
||||
if (std.ascii.eqlIgnoreCase(n, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn slashHasPrefix(name: []const u8) bool {
|
||||
for (SlashCommand.all_names) |n| {
|
||||
if (std.ascii.startsWithIgnoreCase(n, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn slashHasParams(name: []const u8) bool {
|
||||
if (Schema.findByName(name)) |s| return s.hints.len > 0;
|
||||
if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void {
|
||||
if (start >= end) return;
|
||||
const tok = text[start..end];
|
||||
if (tok[0] == '$') {
|
||||
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_var.ptr);
|
||||
return;
|
||||
}
|
||||
if (lp.URL.isCompleteHTTPUrl(tok)) {
|
||||
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_url.ptr);
|
||||
return;
|
||||
}
|
||||
if (std.fmt.parseFloat(f64, tok)) |_| {
|
||||
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_num.ptr);
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
/// Highlight `$LP_*` tokens appearing from `start` onward. `${…}` is not a
|
||||
/// prompt substitution form, so interpolation stays off.
|
||||
fn highlightDollarVars(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void {
|
||||
var i = start;
|
||||
while (js_highlight.nextDollarRef(text, i, text.len, false)) |ref| {
|
||||
c.ic_highlight(henv, @intCast(ref.start), @intCast(ref.end - ref.start), style_var.ptr);
|
||||
i = ref.end;
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints `js_highlight` spans onto isocline's cell attributes.
|
||||
const IcSink = struct {
|
||||
henv: ?*c.ic_highlight_env_t,
|
||||
|
||||
pub fn emit(self: IcSink, start: usize, len: usize, kind: js_highlight.Kind) void {
|
||||
c.ic_highlight(self.henv, @intCast(start), @intCast(len), kind_styles[@intFromEnum(kind)].ptr);
|
||||
}
|
||||
};
|
||||
|
||||
/// Highlight the buffer as JavaScript. Byte offsets are safe (see
|
||||
/// `highlighterCallback`): every token boundary is an ASCII byte and non-ASCII
|
||||
/// bytes advance singly without being highlighted.
|
||||
fn highlightJavaScript(henv: ?*c.ic_highlight_env_t, text: []const u8) void {
|
||||
const sink: IcSink = .{ .henv = henv };
|
||||
_ = js_highlight.tokenize(text, .normal, sink);
|
||||
}
|
||||
|
||||
fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void {
|
||||
var i = start;
|
||||
while (std.mem.indexOfNonePos(u8, text, i, &std.ascii.whitespace)) |tok_start| {
|
||||
i = tok_start;
|
||||
if (text[i] == '\'' or text[i] == '"') {
|
||||
i = Schema.quotedSpanEnd(text, i);
|
||||
c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_string.ptr);
|
||||
continue;
|
||||
}
|
||||
while (i < text.len and !std.ascii.isWhitespace(text[i]) and text[i] != '=') i += 1;
|
||||
const key_end = i;
|
||||
if (i < text.len and text[i] == '=') {
|
||||
c.ic_highlight(henv, @intCast(tok_start), @intCast(key_end - tok_start), style_key.ptr);
|
||||
i += 1;
|
||||
const val_start = i;
|
||||
if (i < text.len and (text[i] == '\'' or text[i] == '"')) {
|
||||
i = Schema.quotedSpanEnd(text, i);
|
||||
c.ic_highlight(henv, @intCast(val_start), @intCast(i - val_start), style_string.ptr);
|
||||
} else {
|
||||
while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1;
|
||||
highlightBareToken(henv, text, val_start, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "valueAt: enum field via positional and kv, partial and empty" {
|
||||
const schema = Schema.findByName("waitForState").?;
|
||||
|
||||
// Leading positional, nothing typed: empty partial, not kv.
|
||||
{
|
||||
const v = valueAt(schema, "", true).?;
|
||||
try std.testing.expect(v.field.enum_values.len > 0);
|
||||
try std.testing.expectEqualStrings("", v.partial);
|
||||
try std.testing.expect(!v.kv);
|
||||
}
|
||||
// Leading positional, partial value.
|
||||
{
|
||||
const v = valueAt(schema, "net", false).?;
|
||||
try std.testing.expectEqualStrings("net", v.partial);
|
||||
try std.testing.expect(!v.kv);
|
||||
}
|
||||
// Explicit `state=` with empty value.
|
||||
{
|
||||
const v = valueAt(schema, "state=", false).?;
|
||||
try std.testing.expectEqualStrings("", v.partial);
|
||||
try std.testing.expect(v.kv);
|
||||
}
|
||||
// Explicit `state=net`.
|
||||
{
|
||||
const v = valueAt(schema, "state=net", false).?;
|
||||
try std.testing.expectEqualStrings("net", v.partial);
|
||||
try std.testing.expect(v.kv);
|
||||
}
|
||||
// Past the only required field — timeout is not an enum, so no value match.
|
||||
{
|
||||
const v = valueAt(schema, "networkidle timeout=", false).?;
|
||||
try std.testing.expectEqual(@as(usize, 0), v.field.enum_values.len);
|
||||
}
|
||||
}
|
||||
|
||||
test "valueAt: getEnv binds the leading positional to its optional name field" {
|
||||
const schema = Schema.findByName("getEnv").?;
|
||||
// `/getEnv ` — fresh positional, even though `name` is optional (0 required).
|
||||
{
|
||||
const v = valueAt(schema, "", true).?;
|
||||
try std.testing.expectEqualStrings("name", v.field.name);
|
||||
try std.testing.expectEqualStrings("", v.partial);
|
||||
try std.testing.expect(!v.kv);
|
||||
}
|
||||
// `/getEnv LP_H` — partial value bound to `name`.
|
||||
{
|
||||
const v = valueAt(schema, "LP_H", false).?;
|
||||
try std.testing.expectEqualStrings("name", v.field.name);
|
||||
try std.testing.expectEqualStrings("LP_H", v.partial);
|
||||
}
|
||||
}
|
||||
|
||||
test "renderSchemaHint: ghosts enum value once typing, keeps template when empty" {
|
||||
const schema = Schema.findByName("waitForState").?;
|
||||
|
||||
const hintStr = struct {
|
||||
fn f(p: [*c]const u8) ?[]const u8 {
|
||||
if (p == null) return null;
|
||||
return std.mem.sliceTo(@as([*:0]const u8, @ptrCast(p)), 0);
|
||||
}
|
||||
}.f;
|
||||
|
||||
// Nothing typed yet: the `<state> …` template, not an arbitrary value.
|
||||
try std.testing.expectEqualStrings(" <state> [timeout=…]", hintStr(renderSchemaHint(schema, "", false)).?);
|
||||
|
||||
// Partial positional ghosts the suffix of the first matching value.
|
||||
try std.testing.expectEqualStrings("workalmostidle", hintStr(renderSchemaHint(schema, "net", false)).?);
|
||||
|
||||
// `state=` ghosts the first value.
|
||||
try std.testing.expectEqualStrings("load", hintStr(renderSchemaHint(schema, "state=", false)).?);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const std = @import("std");
|
||||
const zenai = @import("zenai");
|
||||
const lp = @import("lightpanda");
|
||||
const Config = lp.Config;
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const picker = @import("picker.zig");
|
||||
const string = @import("../string.zig");
|
||||
const Credentials = zenai.provider.Credentials;
|
||||
|
||||
@@ -156,14 +156,14 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
|
||||
}
|
||||
// A single key needs no choice; non-interactive callers (--list-models,
|
||||
// one-shot tasks, pipes) must not block on a prompt — take the first.
|
||||
if (!allow_pick or found.len == 1 or !Terminal.interactiveTty()) {
|
||||
if (!allow_pick or found.len == 1 or !picker.interactiveTty()) {
|
||||
return try finishResolved(allocator, found[0], .detected);
|
||||
}
|
||||
|
||||
var names: [zenai.provider.default_candidates.len][:0]const u8 = undefined;
|
||||
for (found, 0..) |cred, i| names[i] = @tagName(cred.provider);
|
||||
std.debug.print("\n", .{});
|
||||
const idx = Terminal.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch {
|
||||
const idx = picker.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch {
|
||||
return try finishResolved(allocator, found[0], .detected);
|
||||
};
|
||||
return try finishResolved(allocator, found[idx], .picked);
|
||||
@@ -186,12 +186,14 @@ pub const remembered_path = ".lp-agent.zon";
|
||||
/// disabled the LLM (`/provider null`), so the REPL starts in basic mode without
|
||||
/// re-prompting. `effort`/`verbosity` are optional so files predating them still
|
||||
/// parse; null means "use the mode default" (see `Agent.resolveEffort` /
|
||||
/// `Agent.resolveVerbosity`).
|
||||
/// `Agent.resolveVerbosity`). `stream` is likewise optional: null means "use the
|
||||
/// default" (see `resolveStream`).
|
||||
pub const Remembered = struct {
|
||||
provider: ?Config.AiProvider = null,
|
||||
model: []const u8,
|
||||
effort: ?Config.Effort = null,
|
||||
verbosity: ?Config.AgentVerbosity = null,
|
||||
stream: ?bool = null,
|
||||
};
|
||||
|
||||
pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered {
|
||||
@@ -269,6 +271,13 @@ pub fn resolveVerbosity(opts: Config.Agent, remembered: ?Remembered) Config.Agen
|
||||
return Config.agentVerbosity(opts);
|
||||
}
|
||||
|
||||
/// Precedence: remembered `.lp-agent.zon` value > default (on). Streaming has no
|
||||
/// CLI flag — the REPL `/stream` command toggles and persists it.
|
||||
pub fn resolveStream(remembered: ?Remembered) bool {
|
||||
if (remembered) |r| if (r.stream) |s| return s;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub const ReconciledModel = union(enum) {
|
||||
/// Owned by the allocator passed to reconcileModel.
|
||||
use: []u8,
|
||||
@@ -329,4 +338,19 @@ test "parseRemembered: valid file round-trips" {
|
||||
defer std.zon.parse.free(testing.allocator, remembered);
|
||||
try testing.expect(remembered.provider == null);
|
||||
try testing.expectString("some-model", remembered.model);
|
||||
// Absent `stream` is null so pre-streaming files still fall back to the default.
|
||||
try testing.expect(remembered.stream == null);
|
||||
}
|
||||
|
||||
test "parseRemembered: stream field round-trips" {
|
||||
const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .stream = false }").?;
|
||||
defer std.zon.parse.free(testing.allocator, remembered);
|
||||
try testing.expect(remembered.stream == false);
|
||||
}
|
||||
|
||||
test "resolveStream: default on, remembered wins" {
|
||||
try testing.expect(resolveStream(null));
|
||||
try testing.expect(resolveStream(.{ .model = "m", .stream = null }));
|
||||
try testing.expect(resolveStream(.{ .model = "m", .stream = true }));
|
||||
try testing.expect(!resolveStream(.{ .model = "m", .stream = false }));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const ansi = @import("ansi.zig");
|
||||
|
||||
// A pre-colored (truecolor braille) panda. Each line carries its own ANSI and
|
||||
// resets at the end, so it prints as-is; non-empty lines are the visible rows.
|
||||
@@ -82,30 +82,28 @@ comptime {
|
||||
/// Prints the welcome banner: the logo on the left with the title and command
|
||||
/// hints beside it, vertically centered. `llm_active` picks the tagline.
|
||||
pub fn print(llm_active: bool) void {
|
||||
const a = Terminal.ansi;
|
||||
|
||||
var version_buf: [192]u8 = undefined;
|
||||
const version: []const u8 = std.fmt.bufPrint(&version_buf, a.dim ++ "{s}" ++ a.reset, .{lp.build_config.version}) catch "";
|
||||
const version: []const u8 = std.fmt.bufPrint(&version_buf, ansi.dim ++ "{s}" ++ ansi.reset, .{lp.build_config.version}) catch "";
|
||||
|
||||
var lines: [9][]const u8 = undefined;
|
||||
var n: usize = 0;
|
||||
lines[n] = a.bold ++ "Lightpanda Agent" ++ a.reset;
|
||||
lines[n] = ansi.bold ++ "Lightpanda Agent" ++ ansi.reset;
|
||||
n += 1;
|
||||
lines[n] = version;
|
||||
n += 1;
|
||||
lines[n] = "";
|
||||
n += 1;
|
||||
if (llm_active) {
|
||||
lines[n] = a.italic ++ banner_tagline_llm ++ a.reset;
|
||||
lines[n] = ansi.italic ++ banner_tagline_llm ++ ansi.reset;
|
||||
n += 1;
|
||||
} else {
|
||||
lines[n] = a.italic ++ banner_tagline_basic ++ a.reset;
|
||||
lines[n] = ansi.italic ++ banner_tagline_basic ++ ansi.reset;
|
||||
n += 1;
|
||||
lines[n] = a.dim ++ banner_setup ++ a.reset;
|
||||
lines[n] = ansi.dim ++ banner_setup ++ ansi.reset;
|
||||
n += 1;
|
||||
}
|
||||
inline for (banner_hints) |t| {
|
||||
lines[n] = a.dim ++ t ++ a.reset;
|
||||
lines[n] = ansi.dim ++ t ++ ansi.reset;
|
||||
n += 1;
|
||||
}
|
||||
const text = lines[0..n];
|
||||
|
||||
@@ -28,7 +28,7 @@ const Watchdog = @import("../Watchdog.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
const Viewport = @import("Viewport.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
const PermissionState = @import("webapi/Permissions.zig").State;
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
@@ -28,6 +28,7 @@ const Node = @import("webapi/Node.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -111,10 +112,27 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
|
||||
|
||||
switch (target._type) {
|
||||
.node => |node| try self.dispatchNode(node, event, opts),
|
||||
.xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }),
|
||||
.window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }),
|
||||
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves the Window's property event handler for the given event type.
|
||||
fn windowInlineHandler(window: *@import("webapi/Window.zig"), typ: lp.String) ?js.Function.Global {
|
||||
const global_event_handlers = @import("webapi/global_event_handlers.zig");
|
||||
const handler_type = global_event_handlers.fromEventType(typ.str()) orelse return null;
|
||||
return switch (handler_type) {
|
||||
.onerror => window._on_error,
|
||||
.onload => window._on_load,
|
||||
.onblur => window._on_blur,
|
||||
.onfocus => window._on_focus,
|
||||
.onresize => window._on_resize,
|
||||
.onscroll => window._on_scroll,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
// There are a lot of events that can be attached via addEventListener or as
|
||||
// a property, like the XHR events, or window.onload. You might think that the
|
||||
// property is just a shortcut for calling addEventListener, but they are distinct.
|
||||
@@ -124,7 +142,7 @@ pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions;
|
||||
|
||||
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
|
||||
// property handlers. No propagation - just calls the handler and registered listeners.
|
||||
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
|
||||
// Handler can be: null, ?js.Function.Global or js.Function
|
||||
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
|
||||
const frame = self.frame;
|
||||
|
||||
@@ -144,12 +162,19 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons
|
||||
}
|
||||
|
||||
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
{
|
||||
const et = target.asEventTarget();
|
||||
event._target = et;
|
||||
event._dispatch_target = et; // Store original target for composedPath()
|
||||
|
||||
// Retarget the relatedTarget against the dispatch target up front
|
||||
// (DOM dispatch step 4); listeners observe the retargeted value and
|
||||
// it survives the dispatch.
|
||||
if (event.relatedTargetPtr()) |related_ptr| {
|
||||
if (related_ptr.*) |related| {
|
||||
related_ptr.* = getAdjustedTarget(related, et);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const frame = self.frame;
|
||||
@@ -179,6 +204,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
var path_len: usize = 0;
|
||||
var node_path_len: usize = 0;
|
||||
var path_buffer: [128]*EventTarget = undefined;
|
||||
var clear_targets = false;
|
||||
|
||||
// Defer runs even on early return - ensures event phase is reset
|
||||
// and default actions execute (unless prevented)
|
||||
@@ -187,7 +213,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
event._current_target = null;
|
||||
event._stop_propagation = false;
|
||||
event._stop_immediate_propagation = false;
|
||||
if (event._needs_retargeting and node_path_len > 0) {
|
||||
if (clear_targets) {
|
||||
// Don't leak nodes living in a shadow tree: reset the targets
|
||||
// (decided on the pre-dispatch tree, see below).
|
||||
event._target = null;
|
||||
if (event.relatedTargetPtr()) |related_ptr| {
|
||||
related_ptr.* = null;
|
||||
}
|
||||
} else if (event._needs_retargeting and node_path_len > 0) {
|
||||
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
|
||||
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
|
||||
}
|
||||
@@ -200,9 +233,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
if (event._prevent_default) {
|
||||
// can't return in a defer (╯°□°)╯︵ ┻━┻
|
||||
} else if (event._type_string.eql(comptime .wrap("click"))) {
|
||||
Frame.user_input.handleClick(frame, target) catch |err| {
|
||||
log.warn(.event, "frame.click", .{ .err = err });
|
||||
};
|
||||
// Per spec, only a MouseEvent "click" is an activation event, and
|
||||
// the activation target is the nearest inclusive ancestor with
|
||||
// activation behavior (ancestors only for bubbling events).
|
||||
if (event.is(@import("webapi/event/MouseEvent.zig")) != null) {
|
||||
if (Frame.user_input.findClickActivationTarget(target, event._bubbles)) |activation_target| {
|
||||
Frame.user_input.handleClick(frame, activation_target) catch |err| {
|
||||
log.warn(.event, "frame.click", .{ .err = err });
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
|
||||
Frame.user_input.handleKeydown(frame, target, event) catch |err| {
|
||||
log.warn(.event, "frame.keydown", .{ .err = err });
|
||||
@@ -258,6 +298,26 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
}
|
||||
}
|
||||
|
||||
// DOM dispatch: decide up front — on the pre-dispatch tree, so listener
|
||||
// mutations can't affect it — whether target and relatedTarget must be
|
||||
// reset after dispatch because they would expose nodes inside a shadow
|
||||
// tree.
|
||||
if (node_path_len > 0) {
|
||||
const last = path_buffer[node_path_len - 1];
|
||||
if (event._needs_retargeting) {
|
||||
if (rootIsShadowRoot(getAdjustedTarget(event._dispatch_target, last))) {
|
||||
clear_targets = true;
|
||||
}
|
||||
}
|
||||
if (event.relatedTargetPtr()) |related_ptr| {
|
||||
if (related_ptr.*) |related| {
|
||||
if (rootIsShadowRoot(getAdjustedTarget(related, last))) {
|
||||
clear_targets = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const path = path_buffer[0..path_len];
|
||||
|
||||
// Phase 1: Capturing phase (root → target, excluding target)
|
||||
@@ -284,11 +344,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
was_handled = true;
|
||||
event._current_target = target_et;
|
||||
|
||||
const prev_current_event = window._current_event;
|
||||
window._current_event = currentEventForTarget(target_et, event);
|
||||
defer window._current_event = prev_current_event;
|
||||
|
||||
// Inline handlers (e.g. onclick property) follow the same "report,
|
||||
// don't propagate" rule as addEventListener listeners — see Listener.run.
|
||||
ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| {
|
||||
log.warn(.event, "inline handler", .{ .err = err });
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, target_et, .{event}, &caught) catch |err| ret: {
|
||||
log.warn(.event, "inline handler", .{ .err = err, .caught = caught });
|
||||
break :ret null;
|
||||
};
|
||||
processHandlerReturnValue(event, handler_return);
|
||||
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
@@ -299,8 +366,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
}
|
||||
}
|
||||
|
||||
// Per spec, the target is invoked once during the capturing iteration
|
||||
// and once during the bubbling iteration, each with its own snapshot
|
||||
// of the listener list: a bubble listener added while running the
|
||||
// target's capture listeners must run.
|
||||
if (self.base.getListeners(target_et, event._type_string)) |list| {
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(true, opts));
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (self.base.getListeners(target_et, event._type_string)) |list| {
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(false, opts));
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
}
|
||||
@@ -320,14 +397,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
was_handled = true;
|
||||
event._current_target = current_target;
|
||||
|
||||
const prev_current_event = window._current_event;
|
||||
window._current_event = currentEventForTarget(current_target, event);
|
||||
defer window._current_event = prev_current_event;
|
||||
|
||||
const original_target = event._target;
|
||||
if (event._needs_retargeting) {
|
||||
event._target = getAdjustedTarget(original_target, current_target);
|
||||
}
|
||||
|
||||
ls.toLocal(inline_handler).callWithThis(void, current_target, .{event}) catch |err| {
|
||||
log.warn(.event, "inline handler", .{ .err = err });
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: {
|
||||
log.warn(.event, "inline handler", .{ .err = err, .caught = caught });
|
||||
break :ret null;
|
||||
};
|
||||
processHandlerReturnValue(event, handler_return);
|
||||
|
||||
if (event._needs_retargeting) {
|
||||
event._target = original_target;
|
||||
@@ -348,6 +432,19 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
}
|
||||
}
|
||||
|
||||
fn processHandlerReturnValue(event: *Event, handler_return: ?js.Value) void {
|
||||
const ret = handler_return orelse return;
|
||||
if (ret.isFalse() and !event._type_string.eql(comptime .wrap("error"))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Per spec ("invocation target in shadow tree"), window.event is left
|
||||
// undefined while invoking listeners whose target lives in a shadow tree.
|
||||
fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event {
|
||||
return if (rootIsShadowRoot(target)) null else event;
|
||||
}
|
||||
|
||||
const DispatchPhaseOpts = struct {
|
||||
capture_only: ?bool = null,
|
||||
apply_ignore: bool = false,
|
||||
@@ -364,6 +461,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
||||
const frame = self.frame;
|
||||
const base = &self.base;
|
||||
|
||||
const window = frame.window;
|
||||
const prev_current_event = window._current_event;
|
||||
window._current_event = currentEventForTarget(current_target, event);
|
||||
defer window._current_event = prev_current_event;
|
||||
|
||||
// Track dispatch depth for deferred removal
|
||||
base.dispatch_depth += 1;
|
||||
defer {
|
||||
@@ -465,6 +567,17 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
|
||||
// Look up the inline handler for this target
|
||||
const html_element = switch (target._type) {
|
||||
.node => |n| n.is(Element.Html) orelse return null,
|
||||
// The Window stores its event handlers in dedicated fields; an event
|
||||
// propagating to the window must fire them too.
|
||||
.window => |w| return switch (handler_type) {
|
||||
.onerror => w._on_error,
|
||||
.onload => w._on_load,
|
||||
.onblur => w._on_blur,
|
||||
.onfocus => w._on_focus,
|
||||
.onresize => w._on_resize,
|
||||
.onscroll => w._on_scroll,
|
||||
else => null,
|
||||
},
|
||||
else => return null,
|
||||
};
|
||||
|
||||
@@ -477,8 +590,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
|
||||
// DOM spec "retarget": walk original_target out of shadow trees until the
|
||||
// node is visible from current_target's tree.
|
||||
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
const orig_node = switch ((original_target orelse return null)._type) {
|
||||
.node => |n| n,
|
||||
else => return original_target,
|
||||
@@ -500,8 +611,6 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
|
||||
}
|
||||
|
||||
fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
var n: ?*Node = node;
|
||||
while (n) |cur| {
|
||||
if (cur == ancestor) {
|
||||
@@ -519,8 +628,6 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
|
||||
// Whether the target's tree root (without crossing shadow boundaries) is a
|
||||
// shadow root. Used for the spec's post-dispatch "clear targets" step.
|
||||
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
const target = target_ orelse return false;
|
||||
var current: *Node = switch (target._type) {
|
||||
.node => |n| n,
|
||||
@@ -569,12 +676,18 @@ const ActivationState = struct {
|
||||
|
||||
const Input = Element.Html.Input;
|
||||
|
||||
fn create(event: *const Event, target: *Node, frame: *Frame) !?ActivationState {
|
||||
fn create(event: *Event, target: *Node, frame: *Frame) !?ActivationState {
|
||||
if (event._type_string.eql(comptime .wrap("click")) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = target.is(Element.Html.Input) orelse return null;
|
||||
// Per spec, only a MouseEvent "click" is an activation event.
|
||||
if (event.is(@import("webapi/event/MouseEvent.zig")) == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activation_node = Frame.user_input.findClickActivationTarget(target, event._bubbles) orelse return null;
|
||||
const input = activation_node.is(Element.Html.Input) orelse return null;
|
||||
if (input._input_type != .checkbox and input._input_type != .radio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ pub const DispatchDirectOptions = struct {
|
||||
|
||||
/// Direct dispatch for non-DOM targets. No propagation - just calls the property
|
||||
/// handler and registered listeners. Caller is responsible for event ref counting.
|
||||
/// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
|
||||
/// Handler can be: null, ?js.Function.Global or js.Function
|
||||
pub fn dispatchDirect(
|
||||
self: *EventManagerBase,
|
||||
arena: Allocator,
|
||||
@@ -287,11 +287,12 @@ pub fn dispatchDirect(
|
||||
// Call the property handler (e.g., onmessage) if present
|
||||
if (getFunction(handler, &ls.local)) |func| {
|
||||
event._current_target = target;
|
||||
_ = func.callWithThis(void, target, .{event}) catch |err| {
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
_ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| {
|
||||
if (err == error.JsException) {
|
||||
event._listeners_did_throw = true;
|
||||
} else {
|
||||
log.warn(.event, opts.context, .{ .err = err });
|
||||
log.warn(.event, opts.context, .{ .err = err, .caught = caught });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -359,7 +360,6 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
|
||||
}
|
||||
return switch (T) {
|
||||
js.Function => handler,
|
||||
js.Function.Temp => local.toLocal(handler),
|
||||
js.Function.Global => local.toLocal(handler),
|
||||
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
|
||||
};
|
||||
@@ -435,12 +435,19 @@ pub const Listener = struct {
|
||||
comptime context: []const u8,
|
||||
) error{OutOfMemory}!void {
|
||||
switch (self.function) {
|
||||
.value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| {
|
||||
if (err == error.JsException) {
|
||||
event._listeners_did_throw = true;
|
||||
} else {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
}
|
||||
.value => |value| {
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(local);
|
||||
defer try_catch.deinit();
|
||||
local.toLocal(value).callWithThisRethrow(void, event._current_target.?, .{event}) catch |err| switch (err) {
|
||||
// The rethrow variant surfaces a thrown JS exception as
|
||||
// TryCatchRethrow so our enclosing TryCatch holds it.
|
||||
error.JsException, error.TryCatchRethrow => {
|
||||
event._listeners_did_throw = true;
|
||||
reportException(&try_catch, local);
|
||||
},
|
||||
else => log.warn(.event, context, .{ .err = err }),
|
||||
};
|
||||
},
|
||||
.string => |string| {
|
||||
const str = try arena.dupeZ(u8, string.str());
|
||||
@@ -454,31 +461,64 @@ pub const Listener = struct {
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = local.toLocal(obj_global);
|
||||
const handle_event = obj.getFunction("handleEvent") catch |err| blk: {
|
||||
// Getting "handleEvent" threw (e.g. a throwing getter).
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
// Get(handleEvent) can run a getter: a thrown exception is
|
||||
// reported like an exception from the listener itself.
|
||||
const handle_event_value = obj.get("handleEvent") catch |err| {
|
||||
if (err == error.JsException) {
|
||||
event._listeners_did_throw = true;
|
||||
reportException(&try_catch, local);
|
||||
} else {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
}
|
||||
break :blk null;
|
||||
return;
|
||||
};
|
||||
if (handle_event) |handleEvent| {
|
||||
handleEvent.callWithThis(void, obj, .{event}) catch |err| {
|
||||
if (err == error.JsException) {
|
||||
event._listeners_did_throw = true;
|
||||
} else {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// The listener was an object without the handleEvent function
|
||||
// This is throwing a TypeError
|
||||
|
||||
if (!handle_event_value.isFunction()) {
|
||||
// Per the callback interface invocation steps, a
|
||||
// non-callable handleEvent is a reported TypeError.
|
||||
event._listeners_did_throw = true;
|
||||
reportExceptionValue(local, .{
|
||||
.local = local,
|
||||
.handle = local.isolate.createTypeError("handleEvent is not a function"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const handle_event = js.Function{
|
||||
.local = local,
|
||||
.handle = @ptrCast(handle_event_value.handle),
|
||||
};
|
||||
handle_event.callWithThisRethrow(void, obj, .{event}) catch |err| switch (err) {
|
||||
error.JsException, error.TryCatchRethrow => {
|
||||
event._listeners_did_throw = true;
|
||||
reportException(&try_catch, local);
|
||||
},
|
||||
else => log.warn(.event, context, .{ .err = err }),
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Reports a listener exception to the relevant global (firing
|
||||
// window.onerror / an "error" event) without stopping the dispatch.
|
||||
fn reportException(try_catch: *js.TryCatch, local: *const js.Local) void {
|
||||
const exc = try_catch.exceptionValue() orelse return;
|
||||
reportExceptionValue(local, exc);
|
||||
}
|
||||
|
||||
fn reportExceptionValue(local: *const js.Local, exc: js.Value) void {
|
||||
switch (local.ctx.global) {
|
||||
.frame => |frame| frame.window.reportError(exc, frame) catch |err| {
|
||||
log.warn(.event, "listener report error", .{ .err = err });
|
||||
},
|
||||
.worker => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const Function = union(enum) {
|
||||
|
||||
@@ -35,6 +35,8 @@ const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const DOMRect = @import("webapi/DOMRect.zig");
|
||||
const DOMRectReadOnly = @import("webapi/DOMRectReadOnly.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const String = lp.String;
|
||||
@@ -235,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type {
|
||||
|
||||
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
|
||||
// Round to 2ms for privacy (browsers do this)
|
||||
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
|
||||
const time_stamp = (raw_timestamp / 2) * 2;
|
||||
// Same (already coarsened) clock as the performance time origin, so the
|
||||
// timeStamp getter can report it relative to that origin.
|
||||
const time_stamp = @import("webapi/Performance.zig").highResTimestamp();
|
||||
|
||||
return .{
|
||||
._rc = .{},
|
||||
@@ -292,6 +295,22 @@ pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, frame:
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect {
|
||||
const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._slab.allocator());
|
||||
|
||||
const base = chain.get(0);
|
||||
base.* = .{
|
||||
._type = .{ .mutable = chain.get(1) },
|
||||
._x = rect.x,
|
||||
._y = rect.y,
|
||||
._width = rect.width,
|
||||
._height = rect.height,
|
||||
};
|
||||
chain.setLeaf(1, DOMRect{ ._proto = base });
|
||||
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
@@ -335,33 +354,49 @@ pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
}
|
||||
|
||||
pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
const ChildT = @TypeOf(child);
|
||||
|
||||
if (ChildT == Element.Svg) {
|
||||
return self.element(child);
|
||||
}
|
||||
|
||||
const chain = try PrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Svg, ChildT },
|
||||
).allocate(allocator);
|
||||
&.{ EventTarget, Node, Element, Element.Svg, @TypeOf(child) },
|
||||
).allocate(self._slab.allocator());
|
||||
|
||||
try self.setSvgChainBase(chain, tag_name);
|
||||
chain.setLeaf(4, child);
|
||||
return chain.get(4);
|
||||
}
|
||||
|
||||
pub fn svgGraphicsElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, @TypeOf(child) },
|
||||
).allocate(self._slab.allocator());
|
||||
|
||||
try self.setSvgChainBase(chain, tag_name);
|
||||
chain.setMiddle(4, Element.Svg.Graphics.Type);
|
||||
chain.setLeaf(5, child);
|
||||
return chain.get(5);
|
||||
}
|
||||
|
||||
pub fn svgGeometryElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, Element.Svg.Graphics.Geometry, @TypeOf(child) },
|
||||
).allocate(self._slab.allocator());
|
||||
|
||||
try self.setSvgChainBase(chain, tag_name);
|
||||
chain.setMiddle(4, Element.Svg.Graphics.Type);
|
||||
chain.setMiddle(5, Element.Svg.Graphics.Geometry.Type);
|
||||
chain.setLeaf(6, child);
|
||||
return chain.get(6);
|
||||
}
|
||||
|
||||
fn setSvgChainBase(self: *Factory, chain: anytype, tag_name: []const u8) !void {
|
||||
chain.setRoot(EventTarget.Type);
|
||||
chain.setMiddle(1, Node.Type);
|
||||
chain.setMiddle(2, Element.Type);
|
||||
|
||||
// will never allocate, can't fail
|
||||
const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable;
|
||||
|
||||
// Manually set Element.Svg with the tag_name
|
||||
chain.set(3, .{
|
||||
._proto = chain.get(2),
|
||||
._tag_name = tag_name_str,
|
||||
._tag_name = try String.init(self._arena, tag_name, .{}),
|
||||
._type = unionInit(Element.Svg.Type, chain.get(4)),
|
||||
});
|
||||
|
||||
chain.setLeaf(4, child);
|
||||
return chain.get(4);
|
||||
}
|
||||
|
||||
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
|
||||
|
||||
@@ -42,6 +42,7 @@ const Event = @import("webapi/Event.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const HtmlElement = @import("webapi/element/Html.zig");
|
||||
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
|
||||
const Window = @import("webapi/Window.zig");
|
||||
const Location = @import("webapi/Location.zig");
|
||||
const Document = @import("webapi/Document.zig");
|
||||
@@ -50,7 +51,9 @@ const Performance = @import("webapi/Performance.zig");
|
||||
const Screen = @import("webapi/Screen.zig");
|
||||
const VisualViewport = @import("webapi/VisualViewport.zig");
|
||||
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const DOMNodeIterator = @import("webapi/DOMNodeIterator.zig");
|
||||
const Worker = @import("webapi/Worker.zig");
|
||||
const MessagePort = @import("webapi/MessagePort.zig");
|
||||
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
|
||||
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
||||
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
|
||||
@@ -60,7 +63,7 @@ const popover = @import("webapi/element/popover.zig");
|
||||
const slotting = @import("webapi/element/slotting.zig");
|
||||
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
|
||||
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
const sys_url = @import("../sys/url.zig");
|
||||
|
||||
const timestamp = @import("../datetime.zig").timestamp;
|
||||
@@ -68,6 +71,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp;
|
||||
|
||||
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
|
||||
|
||||
pub const preload = @import("frame/preload.zig");
|
||||
pub const observers = @import("frame/observers.zig");
|
||||
pub const user_input = @import("frame/user_input.zig");
|
||||
pub const node_factory = @import("frame/node_factory.zig");
|
||||
@@ -133,10 +137,12 @@ _element_computed_styles: Element.StyleLookup = .empty,
|
||||
_element_datasets: Element.DatasetLookup = .empty,
|
||||
_element_class_lists: Element.ClassListLookup = .empty,
|
||||
_element_rel_lists: Element.RelListLookup = .empty,
|
||||
_element_token_lists: Element.TokenListLookup = .empty,
|
||||
_element_shadow_roots: Element.ShadowRootLookup = .empty,
|
||||
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
|
||||
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
|
||||
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
|
||||
_svg_animated_strings: AnimatedString.Lookup = .empty,
|
||||
|
||||
// Same as above, but for Nodes (slot assigments apply to both Element AND
|
||||
// Text nodes)
|
||||
@@ -186,11 +192,17 @@ _http_owner: HttpClient.Owner = .{},
|
||||
|
||||
// List of active live ranges (for mutation updates per DOM spec)
|
||||
_live_ranges: std.DoublyLinkedList = .{},
|
||||
// Live NodeIterators for the DOM pre-removing steps. Iterators are
|
||||
// slab-allocated (frame lifetime) and never unlinked.
|
||||
_live_node_iterators: std.DoublyLinkedList = .{},
|
||||
|
||||
// List of open BroadcastChannels, used to route postMessage between same-named
|
||||
// channels in this frame's origin
|
||||
_broadcast_channels: std.DoublyLinkedList = .{},
|
||||
|
||||
// List of MessagePorts living in this frame's context.
|
||||
_message_ports: std.DoublyLinkedList = .{},
|
||||
|
||||
// MutationObserver / IntersectionObserver bookkeeping. See frame/observers.zig.
|
||||
_mutation: observers.Mutation = .{},
|
||||
_intersection: observers.Intersection = .{},
|
||||
@@ -209,6 +221,12 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele
|
||||
// The constructor can access this to get the element being upgraded.
|
||||
_upgrading_element: ?*Node = null,
|
||||
|
||||
// Set when materializing the fragment parser's context element. The element
|
||||
// is never inserted into the tree so if its a custom element ,we must not run
|
||||
// its constructor (else we'll end up in an endless loop if the constructor
|
||||
// sets this.innerHTML = '...', which happens).
|
||||
_skip_custom_element_upgrade: bool = false,
|
||||
|
||||
// List of custom elements that were created before their definition was registered
|
||||
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},
|
||||
|
||||
@@ -223,6 +241,10 @@ _factory: *Factory,
|
||||
|
||||
_load_state: LoadState = .waiting,
|
||||
|
||||
// The navigation response's MIME essence, recorded when the first chunk
|
||||
// arrives and applied to the new document (document.contentType) once the
|
||||
// navigation commits. null means the text/html default.
|
||||
_pending_content_type: ?[]const u8 = null,
|
||||
_parse_state: ParseState = .pre,
|
||||
|
||||
/// `frameErrorCallback` swallows the failure into a placeholder page;
|
||||
@@ -460,6 +482,11 @@ pub fn deinit(self: *Frame) void {
|
||||
page.releaseArena(qn.arena);
|
||||
}
|
||||
|
||||
while (self._message_ports.first) |node| {
|
||||
const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node));
|
||||
port.close(); // removes from self._message_ports
|
||||
}
|
||||
|
||||
{
|
||||
// Release all objects we're referencing
|
||||
{
|
||||
@@ -597,16 +624,16 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
self._load_state = .parsing;
|
||||
self._last_navigate_error = null;
|
||||
|
||||
const req_id = self._session.browser.http_client.nextReqId();
|
||||
log.info(.frame, "navigate", .{
|
||||
.url = request_url,
|
||||
.method = opts.method,
|
||||
.reason = opts.reason,
|
||||
.body = opts.body != null,
|
||||
.req_id = req_id,
|
||||
.type = self._type,
|
||||
});
|
||||
|
||||
const http_client = &session.browser.http_client;
|
||||
|
||||
// Handle synthetic navigations: about:blank and blob: URLs
|
||||
const is_about_blank = std.mem.eql(u8, "about:blank", request_url);
|
||||
const is_blob = !is_about_blank and std.mem.startsWith(u8, request_url, "blob:");
|
||||
@@ -661,8 +688,11 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
};
|
||||
const parse_arena = try self.getArena(.medium, "Frame.parseBlob");
|
||||
defer self.releaseArena(parse_arena);
|
||||
// A script executed mid-parse can revoke the blob URL, letting GC
|
||||
// free the buffer under the parser; parse a copy.
|
||||
const html = try parse_arena.dupe(u8, blob._slice);
|
||||
var parser = Parser.init(parse_arena, self.document.asNode(), self, .{ .allow_declarative_shadow = true });
|
||||
parser.parse(blob._slice);
|
||||
parser.parse(html);
|
||||
} else {
|
||||
self.document.injectBlank(self) catch |err| {
|
||||
log.err(.browser, "inject blank", .{ .err = err });
|
||||
@@ -670,6 +700,10 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
};
|
||||
}
|
||||
|
||||
// No real request is made, but CDP still correlates the navigation
|
||||
// events by request id, so consume one.
|
||||
const req_id = http_client.incrReqId();
|
||||
|
||||
session.notification.dispatch(.frame_navigate, &.{
|
||||
.opts = opts,
|
||||
.req_id = req_id,
|
||||
@@ -694,9 +728,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
.timestamp = timestamp(.monotonic),
|
||||
});
|
||||
|
||||
// force next request id manually b/c we won't create a real req.
|
||||
_ = session.browser.http_client.incrReqId();
|
||||
|
||||
if (self.parent == null) {
|
||||
session.navigation._current_navigation_kind = opts.kind;
|
||||
try session.navigation.commitNavigation(self);
|
||||
@@ -706,8 +737,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
return;
|
||||
}
|
||||
|
||||
const http_client = &session.browser.http_client;
|
||||
|
||||
self._http_status = null;
|
||||
self._http_headers = .empty;
|
||||
|
||||
@@ -719,7 +748,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
};
|
||||
self.origin = try URL.getOrigin(self.arena, self.url);
|
||||
|
||||
self._req_id = req_id;
|
||||
self._navigated_options = .{
|
||||
.cdp_id = opts.cdp_id,
|
||||
.reason = opts.reason,
|
||||
@@ -728,14 +756,37 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
.header = if (opts.header) |h| try self.arena.dupeZ(u8, h) else null,
|
||||
};
|
||||
|
||||
var headers = try http_client.newHeaders();
|
||||
try headers.add(lp.Config.HttpHeaders.navigation_accept);
|
||||
if (opts.header) |hdr| {
|
||||
try headers.add(hdr);
|
||||
}
|
||||
if (opts.referer) |ref| {
|
||||
const ref_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", ref }, 0);
|
||||
try headers.add(ref_header);
|
||||
const transfer = try http_client.newRequest(.{
|
||||
.ctx = self,
|
||||
.url = self.url,
|
||||
.frame_id = self._frame_id,
|
||||
.loader_id = self._loader_id,
|
||||
.method = opts.method,
|
||||
.body = opts.body,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.cookie_origin = opts.initiator_url orelse self.url,
|
||||
.resource_type = .document,
|
||||
.notification = self._session.notification,
|
||||
.header_callback = frameHeaderDoneCallback,
|
||||
.data_callback = frameDataCallback,
|
||||
.done_callback = frameDoneCallback,
|
||||
.error_callback = frameErrorCallback,
|
||||
// The frame tracks its navigation by id (_req_id), never by pointer.
|
||||
.shutdown_callback = HttpClient.noopShutdown,
|
||||
}, &self._http_owner);
|
||||
self._req_id = transfer.id;
|
||||
|
||||
{
|
||||
// Ours until submit; clean up if header setup fails.
|
||||
errdefer transfer.deinit();
|
||||
try transfer.req.headers.add(lp.Config.HttpHeaders.navigation_accept);
|
||||
if (opts.header) |hdr| {
|
||||
try transfer.req.headers.add(hdr);
|
||||
}
|
||||
if (opts.referer) |ref| {
|
||||
const ref_header = try std.mem.concatWithSentinel(transfer.arena, u8, &.{ "Referer: ", ref }, 0);
|
||||
try transfer.req.headers.add(ref_header);
|
||||
}
|
||||
}
|
||||
|
||||
// A root navigation issued against a pending Page (i.e. one allocated by
|
||||
@@ -751,7 +802,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
session.notification.dispatch(.frame_navigate, &.{
|
||||
.opts = opts,
|
||||
.url = self.url,
|
||||
.req_id = req_id,
|
||||
.req_id = transfer.id,
|
||||
.frame_id = self._frame_id,
|
||||
.loader_id = self._loader_id,
|
||||
.timestamp = timestamp(.monotonic),
|
||||
@@ -763,37 +814,24 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
|
||||
|
||||
session.navigation._current_navigation_kind = opts.kind;
|
||||
|
||||
self.makeRequest(.{
|
||||
.ctx = self,
|
||||
.url = self.url,
|
||||
.frame_id = self._frame_id,
|
||||
.loader_id = self._loader_id,
|
||||
.method = opts.method,
|
||||
.headers = headers,
|
||||
.body = opts.body,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.cookie_origin = opts.initiator_url orelse self.url,
|
||||
.resource_type = .document,
|
||||
.notification = self._session.notification,
|
||||
.header_callback = frameHeaderDoneCallback,
|
||||
.data_callback = frameDataCallback,
|
||||
.done_callback = frameDoneCallback,
|
||||
.error_callback = frameErrorCallback,
|
||||
}) catch |err| {
|
||||
transfer.submit() catch |err| {
|
||||
log.err(.frame, "navigate request", .{ .url = self.url, .err = err, .type = self._type });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn recordNavigateTelemetry(self: *Frame, tls: bool) void {
|
||||
const TE = @import("../telemetry/telemetry.zig").Event;
|
||||
const context: TE.Navigate.Context = if (self.parent != null)
|
||||
.iframe
|
||||
else if (self.window._opener != null)
|
||||
.popup
|
||||
else
|
||||
.page;
|
||||
lp.metrics.navigate.incr(context);
|
||||
self._session.browser.app.telemetry.record(.{ .navigate = .{
|
||||
.tls = tls,
|
||||
.context = if (self.parent != null)
|
||||
.iframe
|
||||
else if (self.window._opener != null)
|
||||
.popup
|
||||
else
|
||||
.page,
|
||||
.context = context,
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -856,6 +894,21 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url
|
||||
};
|
||||
|
||||
const session = target._session;
|
||||
|
||||
// Re-navigating to the exact current URL is only a reload when the URL
|
||||
// has no fragment. With a fragment it's a fragment navigation per the
|
||||
// HTML "navigate" steps (url equals the document's URL excluding
|
||||
// fragments and url's fragment is non-null): no reload, and since the
|
||||
// fragment didn't change, no hashchange and no new history entry either.
|
||||
if (!opts.force and
|
||||
opts.kind != .reload and
|
||||
std.mem.eql(u8, target.url, resolved_url) and
|
||||
std.mem.indexOfScalar(u8, resolved_url, '#') != null)
|
||||
{
|
||||
session.releaseArena(arena);
|
||||
return;
|
||||
}
|
||||
|
||||
// Short-circuit only true fragment-only navigations (same path/query, different
|
||||
// fragment). Identical URLs fall through and trigger a real reload.
|
||||
const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url);
|
||||
@@ -962,6 +1015,11 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void {
|
||||
return self._session.browser.http_client.request(req, &self._http_owner);
|
||||
}
|
||||
|
||||
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
|
||||
pub fn newRequest(self: *Frame, req: HttpClient.Request) !*HttpClient.Transfer {
|
||||
return self._session.browser.http_client.newRequest(req, &self._http_owner);
|
||||
}
|
||||
|
||||
// Synchronously abort every transfer and WebSocket owned by this frame
|
||||
// and all of its descendants.
|
||||
pub fn abortTransfers(self: *Frame) void {
|
||||
@@ -970,8 +1028,6 @@ pub fn abortTransfers(self: *Frame) void {
|
||||
}
|
||||
const http_client = &self._session.browser.http_client;
|
||||
http_client.abortOwner(&self._http_owner);
|
||||
// abortOwner misses deferred contexts whose transfer already completed.
|
||||
http_client.deferring_layer.cancelFrame(self._frame_id);
|
||||
}
|
||||
|
||||
pub fn documentIsLoaded(self: *Frame) void {
|
||||
@@ -1144,8 +1200,8 @@ fn notifyParentLoadComplete(self: *Frame) void {
|
||||
parent.iframeCompletedLoading(self.iframe.?, self._delays_parent_load);
|
||||
}
|
||||
|
||||
fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResult {
|
||||
var self: *Frame = @ptrCast(@alignCast(response.ctx));
|
||||
fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
|
||||
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
|
||||
|
||||
// Commit point for a pending root navigation. The session has been
|
||||
// holding the OLD page alive during the round-trip; now that response
|
||||
@@ -1157,7 +1213,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
try self._session.commitPendingPage(self._page);
|
||||
}
|
||||
|
||||
const response_url = response.url();
|
||||
const response_url = transfer.req.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);
|
||||
@@ -1169,7 +1225,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
// Page.reload doesn't re-POST form data to the redirect target. Conservative
|
||||
// default — 307/308 technically preserve the method per RFC 7231, but
|
||||
// resubmitting form data is the more dangerous failure mode.
|
||||
if ((response.redirectCount() orelse 0) > 0) {
|
||||
if ((transfer.redirectCount() orelse 0) > 0) {
|
||||
if (self._navigated_options) |*no| {
|
||||
no.method = .GET;
|
||||
no.body = null;
|
||||
@@ -1186,14 +1242,14 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.frame, "navigate header", .{
|
||||
.url = self.url,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.status = transfer.responseStatus(),
|
||||
.content_type = transfer.contentType(),
|
||||
.type = self._type,
|
||||
});
|
||||
}
|
||||
|
||||
self._http_status = response.status();
|
||||
var it = response.headerIterator();
|
||||
self._http_status = transfer.responseStatus();
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
try self._http_headers.append(self.arena, .{
|
||||
.name = try self.arena.dupe(u8, hdr.name),
|
||||
@@ -1218,7 +1274,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
// If the response is a file download, stream its body to disk instead of
|
||||
// parsing it as a page. This sets _parse_state to .download, which the
|
||||
// data/done callbacks below special-case.
|
||||
_ = try self.maybeStartDownload(response);
|
||||
_ = try self.maybeStartDownload(transfer);
|
||||
|
||||
return .proceed;
|
||||
}
|
||||
@@ -1227,7 +1283,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
// treated as a download when Browser.setDownloadBehavior opted in
|
||||
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
|
||||
// See issue #2701.
|
||||
fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
|
||||
fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
|
||||
const session = self._session;
|
||||
switch (session.download_behavior) {
|
||||
.allow, .allow_and_name => {},
|
||||
@@ -1235,7 +1291,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
|
||||
}
|
||||
|
||||
const disposition: HttpClient.Header = blk: {
|
||||
var it = response.headerIterator();
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
|
||||
break :blk hdr;
|
||||
@@ -1281,7 +1337,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
const total: ?u64 = if (response.contentLength()) |cl| cl else null;
|
||||
const total: ?u64 = if (transfer.getContentLength()) |cl| cl else null;
|
||||
|
||||
self._parse_state = .{ .download = .{
|
||||
.guid = guid,
|
||||
@@ -1364,14 +1420,14 @@ fn isUtf16Encoding(charset: []const u8) bool {
|
||||
return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE");
|
||||
}
|
||||
|
||||
fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
var self: *Frame = @ptrCast(@alignCast(response.ctx));
|
||||
fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
|
||||
|
||||
if (self._parse_state == .pre) {
|
||||
// we lazily do this, because we might need the first chunk of data
|
||||
// to sniff the content type
|
||||
var mime: Mime = blk: {
|
||||
if (response.contentType()) |ct| {
|
||||
if (transfer.contentType()) |ct| {
|
||||
break :blk try Mime.parse(ct);
|
||||
}
|
||||
break :blk Mime.sniff(data);
|
||||
@@ -1400,6 +1456,22 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
});
|
||||
}
|
||||
|
||||
// Record the response's MIME essence so the resulting document
|
||||
// reports it (document.contentType). text/html keeps the default, so an
|
||||
// HTML response (parsed from the header, or sniffed when there's no
|
||||
// header) never needs an override; inside this branch the essence can
|
||||
// no longer be text/html.
|
||||
self._pending_content_type = null;
|
||||
if (mime.content_type != .text_html) {
|
||||
if (transfer.contentType()) |ct| {
|
||||
const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len;
|
||||
const essence = std.mem.trim(u8, ct[0..end], " \t");
|
||||
if (essence.len > 0) {
|
||||
self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (mime.content_type) {
|
||||
.text_html => {
|
||||
// Normalize and store the charset using encoding_rs canonical names
|
||||
@@ -1474,6 +1546,11 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
|
||||
//We need to handle different navigation types differently.
|
||||
try self._session.navigation.commitNavigation(self);
|
||||
|
||||
if (self._pending_content_type) |content_type| {
|
||||
self.document._content_type = content_type;
|
||||
self._pending_content_type = null;
|
||||
}
|
||||
|
||||
defer if (comptime IS_DEBUG) {
|
||||
log.debug(.frame, "frame load complete", .{
|
||||
.url = self.url,
|
||||
@@ -1497,6 +1574,8 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
|
||||
|
||||
const raw_html = html.buffer.items;
|
||||
|
||||
preload.prescan(self, raw_html);
|
||||
|
||||
if (std.mem.eql(u8, self.charset, "UTF-8")) {
|
||||
parser.parse(raw_html);
|
||||
} else {
|
||||
@@ -1824,6 +1903,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
|
||||
errdefer popup.deinit();
|
||||
|
||||
popup.window._opener = opts.opener;
|
||||
|
||||
if (opts.name.len > 0 and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
|
||||
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
|
||||
@@ -1843,6 +1923,13 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
|
||||
return err;
|
||||
};
|
||||
|
||||
// A bit hacky. The type of window we return depends on whether or not its
|
||||
// origin is the same as the opener. I believe that we're supposed to do
|
||||
// this check lazily, per function invocation. But we don't have that in
|
||||
// place right now. So the best we can do is set the origin now, before the
|
||||
// async navigation completed.
|
||||
try popup.js.setOrigin(popup.origin);
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
@@ -2027,40 +2114,6 @@ pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !
|
||||
// splitting by route anyway).
|
||||
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
// start prefetching <link rel="preload" as="script" href=...>`
|
||||
pub fn preloadScriptHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
|
||||
if (self.isGoingAway() or self._parse_mode == .fragment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return false;
|
||||
defer self.releaseArena(arena);
|
||||
|
||||
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
|
||||
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
|
||||
// data:/blob: are synthesized locally — no round-trip to hide.
|
||||
return false;
|
||||
}
|
||||
return self._script_manager.preloadScript(element, resolved) catch false;
|
||||
}
|
||||
|
||||
// start prefetching <link rel="modulepreload" href=...>
|
||||
pub fn preloadModuleHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
|
||||
if (self.isGoingAway() or self._parse_mode == .fragment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The url becomes the imported_modules key, which must outlive the fetch
|
||||
// so it lives on the frame arena
|
||||
const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
|
||||
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
|
||||
// data:/blob: are synthesized locally — no round-trip to hide.
|
||||
return false;
|
||||
}
|
||||
|
||||
return self._script_manager.base.preloadModuleHint(element, resolved, self.url) catch false;
|
||||
}
|
||||
|
||||
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
|
||||
// href is passed in as an optimization since the [currently] only callsite has
|
||||
// it, so why look it up again?
|
||||
@@ -2096,18 +2149,10 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
|
||||
|
||||
const http_client = &session.browser.http_client;
|
||||
|
||||
// `syncRequest` below registers a blocking request for this frame, which
|
||||
// makes the DeferringLayer hold back the completion callbacks of every
|
||||
// OTHER in-flight transfer for the frame (e.g. a `<script defer>` still
|
||||
// loading) so they don't run JS while we're on the parser stack. Those
|
||||
// deferred completions must be flushed once the sync fetch returns —
|
||||
// otherwise a `<script defer>` whose fetch finishes during this window is
|
||||
// left at `complete == false` forever, the deferred-script queue never
|
||||
// drains, and `documentIsLoaded` (readyState -> "interactive",
|
||||
// DOMContentLoaded, the load event) never fires. The blocking-`<script>`
|
||||
// path (ScriptManager.addFromElement) and the worker path already flush;
|
||||
// the external-stylesheet path must too.
|
||||
defer http_client.deferring_layer.flushFrame(self._frame_id);
|
||||
// `syncRequest` below registers a blocking request for this frame; the
|
||||
// client's dispatcher holds back every OTHER transfer's callbacks for
|
||||
// the frame while it's registered (they'd run JS on the parser's stack)
|
||||
// and delivers them on the next tick after the sync fetch returns.
|
||||
|
||||
var headers = try http_client.newHeaders();
|
||||
try headers.add("Accept: text/css,*/*;q=0.1");
|
||||
@@ -2135,6 +2180,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
|
||||
.cookie_origin = self.url,
|
||||
.resource_type = .stylesheet,
|
||||
.notification = session.notification,
|
||||
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
|
||||
}) catch |err| {
|
||||
log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved });
|
||||
return self.fireElementEvent(element, comptime .wrap("error"));
|
||||
@@ -2351,8 +2397,19 @@ pub fn dupeSSO(self: *Frame, value: []const u8) !String {
|
||||
|
||||
const RemoveNodeOpts = struct {
|
||||
will_be_reconnected: bool,
|
||||
// Set to false when the caller queues its own combined mutation record
|
||||
notify_observers: bool = true,
|
||||
};
|
||||
pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
|
||||
// NodeIterator pre-removing steps must run while the tree is intact.
|
||||
if (self._live_node_iterators.first != null) {
|
||||
var it: ?*std.DoublyLinkedList.Node = self._live_node_iterators.first;
|
||||
while (it) |link| : (it = link.next) {
|
||||
const iterator: *DOMNodeIterator = @fieldParentPtr("_iterator_link", link);
|
||||
iterator.nodeWillBeRemoved(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture siblings before removing
|
||||
const previous_sibling = child.previousSibling();
|
||||
const next_sibling = child.nextSibling();
|
||||
@@ -2385,7 +2442,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
|
||||
|
||||
slotting.removalSteps(parent, child, self);
|
||||
|
||||
if (observers.hasMutationObservers(self)) {
|
||||
if (opts.notify_observers and observers.hasMutationObservers(self)) {
|
||||
const removed = [_]*Node{child};
|
||||
observers.notifyChildListChange(self, parent, &.{}, &removed, previous_sibling, next_sibling);
|
||||
}
|
||||
@@ -2448,31 +2505,68 @@ pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpt
|
||||
}
|
||||
|
||||
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
|
||||
self.domChanged();
|
||||
const dest_connected = target.isConnected();
|
||||
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
const child_was_connected = child.isConnected();
|
||||
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
|
||||
try self.appendNode(target, child, .{ .child_already_connected = child_was_connected });
|
||||
}
|
||||
return self.moveAllChildren(parent, target, null, .records);
|
||||
}
|
||||
|
||||
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
|
||||
return self.moveAllChildren(fragment, parent, ref_node, .records);
|
||||
}
|
||||
|
||||
pub const MoveChildrenNotify = enum { records, silent_parent };
|
||||
|
||||
// Moves every child of `source` into `parent` (before `ref_node`, or
|
||||
// appended). Per the DOM insert algorithm for fragments, observers get one
|
||||
// removal record on the source and one addition record on the parent, not
|
||||
// one record per child. `.silent_parent` suppresses only the parent record
|
||||
// (replaceChild queues its own combined record); the source record is queued
|
||||
// regardless — the spec's insert algorithm notes it "intentionally does not
|
||||
// pay attention to suppressObservers".
|
||||
pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*Node, notify_mode: MoveChildrenNotify) !void {
|
||||
self.domChanged();
|
||||
const dest_connected = parent.isConnected();
|
||||
const notify = observers.hasMutationObservers(self);
|
||||
|
||||
var it = fragment.childrenIterator();
|
||||
var moved: std.ArrayList(*Node) = .empty;
|
||||
const previous_sibling = if (ref_node) |ref| ref.previousSibling() else parent.lastChild();
|
||||
|
||||
var it = source.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try moved.append(self.call_arena, child);
|
||||
const child_was_connected = child.isConnected();
|
||||
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
|
||||
try self.insertNodeRelative(
|
||||
parent,
|
||||
child,
|
||||
.{ .before = ref_node },
|
||||
.{ .child_already_connected = child_was_connected },
|
||||
);
|
||||
self.removeNode(source, child, .{ .will_be_reconnected = dest_connected, .notify_observers = false });
|
||||
if (ref_node) |ref| {
|
||||
try self.insertNodeRelative(
|
||||
parent,
|
||||
child,
|
||||
.{ .before = ref },
|
||||
.{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false },
|
||||
);
|
||||
} else {
|
||||
try self.appendNode(parent, child, .{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false });
|
||||
}
|
||||
}
|
||||
|
||||
if (notify and moved.items.len > 0) {
|
||||
observers.notifyChildListChange(self, source, &.{}, moved.items, null, null);
|
||||
if (notify_mode == .records) {
|
||||
observers.notifyChildListChange(self, parent, moved.items, &.{}, previous_sibling, ref_node);
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes moved into a not-yet-started script run the script's
|
||||
// children-changed steps first (whatwg/html#10188), then each inserted
|
||||
// node's own ready work runs, in tree order, with everything in place.
|
||||
// Ready work only happens on becoming connected, so a detached
|
||||
// destination has none.
|
||||
if (parent.isConnected()) {
|
||||
if (parent.is(Element.Html.Script)) |script| {
|
||||
if (!script._executed) {
|
||||
try self.nodeIsReady(false, parent);
|
||||
}
|
||||
}
|
||||
for (moved.items) |child| {
|
||||
try self.nodeIsReadySubtree(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2484,6 +2578,12 @@ const InsertNodeRelative = union(enum) {
|
||||
const InsertNodeOpts = struct {
|
||||
child_already_connected: bool = false,
|
||||
adopting_to_new_document: bool = false,
|
||||
// Set to false when the caller queues its own combined mutation record
|
||||
notify_observers: bool = true,
|
||||
// Set to false for multi-node insertions (fragments): the caller runs
|
||||
// the ready work itself once every node is in place, so an earlier
|
||||
// script observes its later siblings already inserted.
|
||||
run_ready: bool = true,
|
||||
};
|
||||
pub fn insertNodeRelative(self: *Frame, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
|
||||
return self._insertNodeRelative(false, parent, child, relative, opts);
|
||||
@@ -2539,12 +2639,14 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
|
||||
}
|
||||
}
|
||||
|
||||
// The parser path does its own (limited) notification and connected-callback
|
||||
// work, then returns.
|
||||
// The parser path does its own (limited) notification and
|
||||
// connected-callback work, then returns.
|
||||
if (comptime from_parser) {
|
||||
// Of the parser insertions, only fragment parses (innerHTML) mutate a
|
||||
// live tree; the initial document parse suppresses notifications.
|
||||
if (self._parse_mode == .fragment) {
|
||||
// Main-document parser insertions notify per node: scripts running
|
||||
// during parsing can observe the document. Fragment parses
|
||||
// (innerHTML et al.) stay silent; Node.setHTML queues one combined
|
||||
// "replace all" record instead.
|
||||
if (self._parse_mode != .fragment) {
|
||||
self.notifyChildInserted(parent, child);
|
||||
}
|
||||
|
||||
@@ -2564,20 +2666,28 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
|
||||
|
||||
const parent_is_connected = parent.isConnected();
|
||||
|
||||
// nodeIsReady resolves the node's owning frame itself (only for the few node
|
||||
// types that have ready work), so pass the incumbent `self`.
|
||||
try self.nodeIsReady(false, child);
|
||||
// Mutation records queue synchronously at insertion, before any script
|
||||
// runs: an inserted script can observe its own record via takeRecords.
|
||||
if (opts.notify_observers) {
|
||||
self.notifyChildInserted(parent, child);
|
||||
}
|
||||
|
||||
// Check if text was added to a script that hasn't started yet.
|
||||
if (child._type == .cdata and parent_is_connected) {
|
||||
// Inserting into a not-yet-started script runs the script's
|
||||
// children-changed steps first, then the inserted nodes' own ready work
|
||||
// (whatwg/html#10188: the outer script executes before an inner one).
|
||||
// Ready work only happens on becoming connected, so a detached parent
|
||||
// has none.
|
||||
if (opts.run_ready and parent_is_connected) {
|
||||
if (parent.is(Element.Html.Script)) |script| {
|
||||
if (!script._executed) {
|
||||
try self.nodeIsReady(false, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.notifyChildInserted(parent, child);
|
||||
// nodeIsReady resolves the node's owning frame itself (only for the few node
|
||||
// types that have ready work), so pass the incumbent `self`.
|
||||
try self.nodeIsReadySubtree(child);
|
||||
}
|
||||
|
||||
if (opts.child_already_connected and !opts.adopting_to_new_document) {
|
||||
// The child is already connected in the same document, we don't have to reconnect it.
|
||||
@@ -2800,21 +2910,31 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F
|
||||
}
|
||||
node._children = first._children;
|
||||
|
||||
if (observers.hasMutationObservers(self)) {
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
child._parent = node;
|
||||
// Notify mutation observers for each unwrapped child
|
||||
const previous_sibling = child.previousSibling();
|
||||
const next_sibling = child.nextSibling();
|
||||
const added = [_]*Node{child};
|
||||
observers.notifyChildListChange(self, node, &added, &.{}, previous_sibling, next_sibling);
|
||||
}
|
||||
} else {
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
child._parent = node;
|
||||
}
|
||||
// No mutation records for the unwrapped children either; see the comment
|
||||
// about fragment parses in _insertNodeRelative.
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
child._parent = node;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs the "ready" work for an inserted node and, when it's an element with
|
||||
// children, for its descendants in tree order: appending a subtree
|
||||
// containing scripts must execute them all, after the whole insertion.
|
||||
fn nodeIsReadySubtree(self: *Frame, node: *Node) !void {
|
||||
if (node._type != .element or node.firstChild() == null) {
|
||||
return self.nodeIsReady(false, node);
|
||||
}
|
||||
|
||||
// Scripts can mutate the tree. Safe to do this since nodeIsReady re-checks
|
||||
// connectivity.
|
||||
var elements: std.ArrayList(*Node) = .empty;
|
||||
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
|
||||
while (tw.next()) |el| {
|
||||
try elements.append(self.call_arena, el.asNode());
|
||||
}
|
||||
for (elements.items) |el| {
|
||||
try self.nodeIsReady(false, el);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2839,6 +2959,16 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
|
||||
// walk, so we only do it once we've matched a node type that has ready work
|
||||
// (the common text/element insertion does nothing here). The parser inserts
|
||||
// into its own document, so from_parser always uses `self`.
|
||||
// Scripts, iframes, links and styles activate on becoming connected;
|
||||
// appending them to a detached parent does nothing (they run/load later
|
||||
// if the subtree gets inserted into the document).
|
||||
if (comptime from_parser == false) {
|
||||
switch (node._type) {
|
||||
.element => if (!node.isConnected()) return,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
if (node.is(Element.Html.Script)) |script| {
|
||||
if ((comptime from_parser == false) and script._src.len == 0) {
|
||||
// Script was added via JavaScript without a src attribute.
|
||||
@@ -3131,6 +3261,11 @@ const SubmitFormOpts = struct {
|
||||
pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void {
|
||||
const form = form_ orelse return;
|
||||
|
||||
if (submit_opts.fire_event and form.asNode().isConnected() == false) {
|
||||
// interactive submission (e.g. submit button) noop if the form is disconnected
|
||||
return;
|
||||
}
|
||||
|
||||
// see the `_constructing_entry_list` field documentation
|
||||
if (form._constructing_entry_list) {
|
||||
return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ pub const ContentTypeEnum = enum {
|
||||
text_plain,
|
||||
text_css,
|
||||
text_markdown,
|
||||
text_event_stream,
|
||||
image_jpeg,
|
||||
image_gif,
|
||||
image_png,
|
||||
@@ -57,6 +58,7 @@ pub const ContentType = union(ContentTypeEnum) {
|
||||
text_plain: void,
|
||||
text_css: void,
|
||||
text_markdown: void,
|
||||
text_event_stream: void,
|
||||
image_jpeg: void,
|
||||
image_gif: void,
|
||||
image_png: void,
|
||||
@@ -76,6 +78,7 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
|
||||
.text_plain => "text/plain",
|
||||
.text_markdown => "text/markdown",
|
||||
.text_css => "text/css",
|
||||
.text_event_stream => "text/event-stream",
|
||||
.image_jpeg => "image/jpeg",
|
||||
.image_png => "image/png",
|
||||
.image_gif => "image/gif",
|
||||
@@ -363,6 +366,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||
@"text/css",
|
||||
@"text/plain",
|
||||
@"text/markdown",
|
||||
@"text/event-stream",
|
||||
|
||||
@"text/javascript",
|
||||
@"application/javascript",
|
||||
@@ -382,6 +386,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||
.@"text/plain" => .{ .text_plain = {} },
|
||||
.@"text/css" => .{ .text_css = {} },
|
||||
.@"text/markdown" => .{ .text_markdown = {} },
|
||||
.@"text/event-stream" => .{ .text_event_stream = {} },
|
||||
.@"image/jpeg" => .{ .image_jpeg = {} },
|
||||
.@"image/png" => .{ .image_png = {} },
|
||||
.@"image/gif" => .{ .image_gif = {} },
|
||||
|
||||
@@ -17,16 +17,19 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const Frame = @import("Frame.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const Factory = @import("Factory.zig");
|
||||
const Viewport = @import("Viewport.zig");
|
||||
|
||||
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
@@ -81,11 +84,10 @@ identity: js.Identity = .{},
|
||||
// weak-callback safety.
|
||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *js.FinalizerCallback) = .empty,
|
||||
|
||||
// Tracked global v8 objects that need to be released when the Page tears down.
|
||||
globals: std.ArrayList(v8.Global) = .empty,
|
||||
|
||||
// Temporary v8 globals that can be released early. Key is global.data_ptr.
|
||||
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
// Persisted v8 handles owned by this Page. Handles that outlive the Page are
|
||||
// reset on teardown; handles that can be released early are dropped
|
||||
// individually. See js.GlobalTracker.
|
||||
globals: js.GlobalTracker,
|
||||
|
||||
// Double buffered so that, as we process one list of queued navigations, new
|
||||
// entries are added to the separate buffer. Prevents endless navigation loops
|
||||
@@ -116,6 +118,10 @@ popups: std.ArrayList(*Frame) = .empty,
|
||||
// ideal from a memory point of view).
|
||||
closed_frames: std.ArrayList(*Frame) = .empty,
|
||||
|
||||
// SharedWorkerGlobalScopes created by this Page's frames (also registered in
|
||||
// session.shared_workers so other pages can connect).
|
||||
shared_workers: std.ArrayList(*SharedWorkerGlobalScope) = .empty,
|
||||
|
||||
// In-flight navigation for a root page. When not null, this page will "replace"
|
||||
// the referenced page once the response header arrives. This is necessary
|
||||
// because, during navigation, both the "old" and "new" pages remain addressable
|
||||
@@ -144,6 +150,7 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
|
||||
.frame = undefined,
|
||||
.frame_arena = frame_arena,
|
||||
.factory = Factory.init(frame_arena),
|
||||
.globals = .init(session.browser.app.allocator),
|
||||
};
|
||||
self.queued_navigation = &self.queued_navigation_1;
|
||||
|
||||
@@ -165,7 +172,13 @@ pub fn deinit(self: *Page) void {
|
||||
|
||||
self.frame.deinit();
|
||||
|
||||
for (self.shared_workers.items) |scope| {
|
||||
scope.deinit();
|
||||
}
|
||||
self.shared_workers = .empty;
|
||||
|
||||
const session = self.session;
|
||||
lp.metrics.js_heap_size_bytes.observe(session.browser.env.isolate.getHeapStatistics().total_physical_size);
|
||||
defer session.browser.env.memoryPressureNotification(.moderate);
|
||||
|
||||
self.identity.deinit();
|
||||
@@ -180,20 +193,7 @@ pub fn deinit(self: *Page) void {
|
||||
self.finalizer_callbacks = .empty;
|
||||
}
|
||||
|
||||
{
|
||||
for (self.globals.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
self.globals = .empty;
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.temps.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
self.temps = .empty;
|
||||
}
|
||||
self.globals.deinit();
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(self.origins.count() == 0);
|
||||
|
||||
@@ -22,7 +22,7 @@ const lp = @import("lightpanda");
|
||||
const js = @import("js/js.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
@@ -170,7 +170,7 @@ fn _wait(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
|
||||
if (elapsed >= timeout_ms) {
|
||||
return .timeout;
|
||||
}
|
||||
try self.http_client.tick(@min(timeout_ms - elapsed, 200), .all);
|
||||
try self.http_client.tick(@min(timeout_ms - elapsed, 200));
|
||||
break :done_blk 0;
|
||||
},
|
||||
};
|
||||
@@ -220,13 +220,12 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
|
||||
try browser.runMacrotasks();
|
||||
}
|
||||
|
||||
const http_active = http_client.http_active;
|
||||
const http_next_tick = http_client.next_tick_count;
|
||||
const total_http_activity = http_active + http_next_tick + http_client.interception_layer.intercepted;
|
||||
const total_network_activity = total_http_activity + http_client.ws_active;
|
||||
const activity = http_client.activity();
|
||||
const total_http_activity = activity.http;
|
||||
const total_network_activity = activity.total();
|
||||
|
||||
const ms_to_next_macrotask = browser.msToNextMacrotask();
|
||||
const network_idle = total_network_activity == 0 and http_client.queue.first == null and http_client.ready_queue.first == null;
|
||||
const network_idle = activity.idle();
|
||||
const is_done = ms_to_next_macrotask == null and network_idle;
|
||||
|
||||
// _we_ have nothing to run, but v8 is working on background tasks. We'll
|
||||
@@ -302,10 +301,17 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
|
||||
// for a client message; loop back and run macrotasks instead.
|
||||
ms_to_wait = @min(ms_to_wait, 10);
|
||||
}
|
||||
try http_client.tick(@intCast(ms_to_wait), .all);
|
||||
try http_client.tick(@intCast(ms_to_wait));
|
||||
return .{ .ok = 0 };
|
||||
}
|
||||
|
||||
// WebSocket often remain open forever. We can't wait for them to "complete".
|
||||
// But, if we have web socket connections, we still want to make progress on
|
||||
// them, so we tick with no delay.
|
||||
if (activity.ws_conns > 0 or activity.ws_events > 0) {
|
||||
try http_client.tick(0);
|
||||
}
|
||||
|
||||
return .done;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const URL = @import("URL.zig");
|
||||
@@ -46,7 +46,8 @@ frame: *Frame,
|
||||
// "load" event).
|
||||
frame_notified_of_completion: bool,
|
||||
|
||||
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
|
||||
// Scripts loaded based on a <link rel=preload as=script href=...> found during
|
||||
// parsing, keyed by resolved URL.
|
||||
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
|
||||
|
||||
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
|
||||
@@ -104,7 +105,8 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
|
||||
|
||||
// Returns true when a fetch was started: the link's load/error event fires
|
||||
// when the fetch settles. false (duplicate hint) = no event will fire.
|
||||
pub fn preloadScript(self: *ScriptManager, element: *Element.Html, url: []const u8) !bool {
|
||||
// element is null when the hint came from the prescan rather than a <link>.
|
||||
pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const u8) !bool {
|
||||
if (self.preloaded_scripts.contains(url)) {
|
||||
return false;
|
||||
}
|
||||
@@ -169,7 +171,7 @@ fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
|
||||
const entry = self.preloaded_scripts.getPtr(url) orelse return null;
|
||||
switch (entry.state) {
|
||||
.loading => {
|
||||
_ = client.tick(200, .sync_wait) catch return null;
|
||||
_ = client.tickSync(200) catch return null;
|
||||
continue;
|
||||
},
|
||||
.done => |script| {
|
||||
@@ -225,54 +227,28 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
return;
|
||||
};
|
||||
|
||||
var handover = false;
|
||||
const frame = self.frame;
|
||||
const base_url = frame.base();
|
||||
|
||||
// A consumed preload (waitForPreload below) is owned by us: its buffer is
|
||||
// borrowed by `script`, so it must outlive eval.
|
||||
var consumed_preload: ?*Script = null;
|
||||
defer if (consumed_preload) |p| {
|
||||
p.deinit();
|
||||
const src = element.getAttributeSafe(comptime .wrap("src")) orelse {
|
||||
return self.addInlineScript(script_element, kind);
|
||||
};
|
||||
|
||||
// The script is remote (even data: and blob: are synthesized via HttpClient)
|
||||
|
||||
// Set once arena ownership is resolved — transferred to `script`, or
|
||||
// released early on the adoption path — so the errdefer can't double-free.
|
||||
var handover = false;
|
||||
|
||||
const arena = try frame.getArena(.large, "SM.addFromElement");
|
||||
errdefer if (!handover) {
|
||||
errdefer if (handover == false) {
|
||||
frame.releaseArena(arena);
|
||||
};
|
||||
|
||||
var source: Script.Source = undefined;
|
||||
var remote_url: ?[:0]const u8 = null;
|
||||
const base_url = frame.base();
|
||||
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||
// data: and blob: srcs flow through the normal request path; HttpClient
|
||||
// synthesizes the response. Execution mode (blocking vs async/defer) is
|
||||
// attribute-driven, the same as any other src.
|
||||
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
|
||||
source = .{ .remote = .{} };
|
||||
} else {
|
||||
var buf = std.Io.Writer.Allocating.init(arena);
|
||||
try element.asNode().getChildTextContent(&buf.writer);
|
||||
try buf.writer.writeByte(0);
|
||||
const data = buf.written();
|
||||
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
|
||||
if (inline_source.len == 0) {
|
||||
// we haven't set script_element._executed = true yet, which is good.
|
||||
// If content is appended to the script, we will execute it then.
|
||||
frame.releaseArena(arena);
|
||||
return;
|
||||
}
|
||||
source = .{ .@"inline" = inline_source };
|
||||
}
|
||||
|
||||
// Only set _executed (already-started) when we actually have content to execute
|
||||
const remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
|
||||
script_element._executed = true;
|
||||
const is_inline = source == .@"inline";
|
||||
|
||||
const mode: Script.Extra.FrameExtra.Mode = blk: {
|
||||
if (source == .@"inline") {
|
||||
break :blk if (kind == .module) .@"defer" else .normal;
|
||||
}
|
||||
|
||||
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
|
||||
break :blk .async;
|
||||
}
|
||||
@@ -294,54 +270,84 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
break :blk .normal;
|
||||
};
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
log.debug(.http, "script queue", .{
|
||||
.ctx = ctx,
|
||||
.url = remote_url,
|
||||
.element = element,
|
||||
.stack = ls.local.stackTrace() catch "???",
|
||||
});
|
||||
}
|
||||
|
||||
const frame_extra: Script.Extra = .{ .frame = .{
|
||||
.kind = kind,
|
||||
.mode = mode,
|
||||
.script_element = script_element,
|
||||
.frame = frame,
|
||||
} };
|
||||
|
||||
if (mode != .normal) {
|
||||
var preloaded = self.takePreload(remote_url);
|
||||
if (preloaded == null and kind == .module) {
|
||||
preloaded = self.base.takeModuleHint(remote_url);
|
||||
}
|
||||
if (preloaded) |pre| {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script adopt", .{ .url = remote_url, .ctx = ctx, .state = if (pre.complete) "done" else "loading" });
|
||||
}
|
||||
pre.extra = frame_extra;
|
||||
|
||||
// The adopted Script has its own arena; ours held only the URL
|
||||
// resolution, which nothing below needs.
|
||||
handover = true;
|
||||
frame.releaseArena(arena);
|
||||
|
||||
if (pre.complete and mode == .async) {
|
||||
// The fetch already finished, so no doneCallback will move it
|
||||
// to ready_scripts; queue it there directly.
|
||||
self.base.ready_scripts.append(&pre.node);
|
||||
} else {
|
||||
self.base.scriptList(pre).append(&pre.node);
|
||||
}
|
||||
if (pre.complete) {
|
||||
// ...and no doneCallback will trigger evaluation.
|
||||
self.base.evaluate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.node = .{},
|
||||
.arena = arena,
|
||||
.manager = &self.base,
|
||||
.source = source,
|
||||
.complete = is_inline,
|
||||
.status = if (is_inline) 200 else 0,
|
||||
.url = remote_url orelse base_url,
|
||||
.extra = .{ .frame = .{
|
||||
.kind = kind,
|
||||
.mode = mode,
|
||||
.script_element = script_element,
|
||||
.frame = frame,
|
||||
} },
|
||||
.source = .{ .remote = .{} },
|
||||
.complete = false,
|
||||
.url = remote_url,
|
||||
.extra = frame_extra,
|
||||
};
|
||||
|
||||
const is_blocking = mode == .normal;
|
||||
if (mode == .normal) {
|
||||
// Blocking: fetch synchronously and evaluate before the parser resumes.
|
||||
|
||||
// Once parsing is done, the deferred-script batch has already drained and
|
||||
// won't run again, so a non-blocking script inserted afterwards would go
|
||||
// unprocessed. Run it immediately instead. Remote scripts still need to
|
||||
// be queued so they execute when their fetch completes.
|
||||
const run_immediately = is_blocking or (self.base.static_scripts_done and remote_url == null);
|
||||
if (run_immediately == false) {
|
||||
self.base.scriptList(script).append(&script.node);
|
||||
}
|
||||
// A consumed preload (waitForPreload below) is owned by us: its buffer
|
||||
// is borrowed by `script`, so it must outlive eval.
|
||||
var consumed_preload: ?*Script = null;
|
||||
defer if (consumed_preload) |p| {
|
||||
p.deinit();
|
||||
};
|
||||
|
||||
if (remote_url) |url| {
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
{
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.endEvaluationWindow(was_evaluating);
|
||||
|
||||
log.debug(.http, "script queue", .{
|
||||
.ctx = ctx,
|
||||
.url = remote_url.?,
|
||||
.element = element,
|
||||
.stack = ls.local.stackTrace() catch "???",
|
||||
});
|
||||
}
|
||||
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.endEvaluationWindow(was_evaluating);
|
||||
|
||||
if (is_blocking) {
|
||||
if (self.waitForPreload(url)) |pre| {
|
||||
if (self.waitForPreload(remote_url)) |pre| {
|
||||
// There was a preloaded script, we borrow it's source and status
|
||||
consumed_preload = pre;
|
||||
script.source = pre.source;
|
||||
@@ -349,7 +355,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
script.complete = true;
|
||||
} else {
|
||||
const response = try self.base.client.syncRequest(arena, .{
|
||||
.url = url,
|
||||
.url = remote_url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
@@ -358,54 +364,114 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
|
||||
});
|
||||
|
||||
script.source = .{ .remote = response.body };
|
||||
script.status = response.status;
|
||||
script.complete = true;
|
||||
}
|
||||
} else {
|
||||
errdefer {
|
||||
self.base.scriptList(script).remove(&script.node);
|
||||
// Let the outer errdefer handle releasing the arena if client.request fails
|
||||
}
|
||||
|
||||
try frame.makeRequest(.{
|
||||
.ctx = script,
|
||||
.url = url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
.header_callback = Script.headerCallback,
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
});
|
||||
handover = true;
|
||||
}
|
||||
|
||||
handover = true;
|
||||
if (script.status < 200 or script.status > 299) {
|
||||
log.info(.http, "script load error", .{ .status = script.status });
|
||||
script.executeCallback(comptime .wrap("error"));
|
||||
script.deinit();
|
||||
return;
|
||||
}
|
||||
|
||||
return self.evalNow(script);
|
||||
}
|
||||
|
||||
if (run_immediately == false) {
|
||||
// async/defer: queue the script and fetch in the background; doneCallback
|
||||
// routes it through the ready_scripts / defer_scripts draining.
|
||||
self.base.scriptList(script).append(&script.node);
|
||||
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
defer self.base.endEvaluationWindow(was_evaluating);
|
||||
|
||||
errdefer self.base.scriptList(script).remove(&script.node);
|
||||
try frame.makeRequest(.{
|
||||
.ctx = script,
|
||||
.url = remote_url,
|
||||
.method = .GET,
|
||||
.frame_id = frame._frame_id,
|
||||
.loader_id = frame._loader_id,
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &frame._session.cookie_jar,
|
||||
.cookie_origin = frame.url,
|
||||
.resource_type = .script,
|
||||
.notification = frame._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
.header_callback = Script.headerCallback,
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
// Nothing holds the transfer; teardown cleanup runs through
|
||||
// the manager's script lists.
|
||||
.shutdown_callback = HttpClient.noopShutdown,
|
||||
});
|
||||
handover = true;
|
||||
}
|
||||
|
||||
// A <script> with no src. Runs synchronously right now, except an inline
|
||||
// module during parsing, which waits its turn in defer_scripts.
|
||||
fn addInlineScript(self: *ScriptManager, script_element: *Element.Html.Script, kind: Script.Extra.FrameExtra.Kind) !void {
|
||||
const node = script_element.asElement().asNode();
|
||||
|
||||
const source_len = node.childTextContentLen();
|
||||
if (source_len == 0) {
|
||||
// if content is appended later, we'll execute it then since
|
||||
// script_element._executed is still false
|
||||
return;
|
||||
}
|
||||
|
||||
// This will flush any deferred scripts.
|
||||
defer self.base.client.deferring_layer.flushFrame(self.base.owner.frameId());
|
||||
const frame = self.frame;
|
||||
const arena = try frame.getArena(source_len + @sizeOf(Script) + 1, "SM.addInlineScript");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
if (script.status < 200 or script.status > 299) {
|
||||
log.info(.http, "script load error", .{ .status = script.status });
|
||||
script.executeCallback(comptime .wrap("error"));
|
||||
script.deinit();
|
||||
const source = blk: {
|
||||
const buf = try arena.alloc(u8, source_len + 1);
|
||||
var writer: std.Io.Writer = .fixed(buf);
|
||||
try node.getChildTextContent(&writer);
|
||||
buf[source_len] = 0;
|
||||
break :blk buf[0..source_len :0];
|
||||
};
|
||||
|
||||
script_element._executed = true;
|
||||
|
||||
const mode: Script.Extra.FrameExtra.Mode = if (kind == .module) .@"defer" else .normal;
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.node = .{},
|
||||
.arena = arena,
|
||||
.manager = &self.base,
|
||||
.source = .{ .@"inline" = source },
|
||||
.complete = true,
|
||||
.status = 200,
|
||||
.url = frame.base(),
|
||||
.extra = .{ .frame = .{
|
||||
.kind = kind,
|
||||
.mode = mode,
|
||||
.script_element = script_element,
|
||||
.frame = frame,
|
||||
} },
|
||||
};
|
||||
|
||||
// An inline module found during parsing waits its turn in document order.
|
||||
// Once parsing is done, the deferred batch has already drained and won't
|
||||
// run again, so run it immediately instead.
|
||||
if (mode == .@"defer" and self.base.static_scripts_done == false) {
|
||||
self.base.scriptList(script).append(&script.node);
|
||||
return;
|
||||
}
|
||||
|
||||
self.evalNow(script);
|
||||
}
|
||||
|
||||
fn evalNow(self: *ScriptManager, script: *Script) void {
|
||||
// could have already been evaluating if this is dynamically added
|
||||
const was_evaluating = self.base.is_evaluating;
|
||||
self.base.is_evaluating = true;
|
||||
@@ -421,6 +487,14 @@ pub fn staticScriptsDone(self: *ScriptManager) void {
|
||||
self.base.staticScriptsDone();
|
||||
}
|
||||
|
||||
// Removes and returns the preload entry for `url` in whatever state it's in.
|
||||
fn takePreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
|
||||
const kv = self.preloaded_scripts.fetchRemove(url) orelse return null;
|
||||
return switch (kv.value.state) {
|
||||
inline else => |script| script,
|
||||
};
|
||||
}
|
||||
|
||||
const PreloadedScript = struct {
|
||||
state: State,
|
||||
|
||||
@@ -437,6 +511,11 @@ const PreloadedScript = struct {
|
||||
|
||||
fn doneCallback(ctx: *anyopaque) !void {
|
||||
const script: *Script = @ptrCast(@alignCast(ctx));
|
||||
if (script.extra != .preload) {
|
||||
// Adopted by a real <script> element (addFromElement) while the
|
||||
// fetch was in flight; complete it as a normal frame script.
|
||||
return Script.doneCallback(ctx);
|
||||
}
|
||||
script.complete = true;
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script fetch complete", .{ .req = script.url });
|
||||
@@ -449,6 +528,11 @@ const PreloadedScript = struct {
|
||||
|
||||
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
const script: *Script = @ptrCast(@alignCast(ctx));
|
||||
if (script.extra != .preload) {
|
||||
// Adopted mid-flight; fail it as a normal frame script (list
|
||||
// unlink, error event on the element and the hint link).
|
||||
return Script.errorCallback(ctx, err);
|
||||
}
|
||||
if (script.status == 404) {
|
||||
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
|
||||
} else {
|
||||
@@ -468,6 +552,11 @@ const PreloadedScript = struct {
|
||||
// errorCallback, since the owner is being torn down.
|
||||
fn shutdownCallback(ctx: *anyopaque) void {
|
||||
const script: *Script = @ptrCast(@alignCast(ctx));
|
||||
if (script.extra != .preload) {
|
||||
// Adopted: the Script lives in the manager's script lists and is
|
||||
// reaped by reset(), same as any async script (noopShutdown).
|
||||
return;
|
||||
}
|
||||
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
|
||||
_ = self.preloaded_scripts.remove(script.url);
|
||||
script.deinit();
|
||||
|
||||
@@ -20,8 +20,8 @@ const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const http = @import("../network/http.zig");
|
||||
const HttpClient = @import("../network/HttpClient.zig");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const Session = @import("Session.zig");
|
||||
@@ -216,7 +216,12 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co
|
||||
}
|
||||
|
||||
const PreloadOpts = struct {
|
||||
// set when the preload comes from a <link rel=modulepreload> hint
|
||||
// true when the preload is a hint (modulepreload link or prescan) and can
|
||||
// be taken by anyone.
|
||||
hint: bool = false,
|
||||
|
||||
// the <link rel=modulepreload> element to fire load/error on, when the
|
||||
// hint came from one.
|
||||
hint_element: ?*Element.Html = null,
|
||||
};
|
||||
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void {
|
||||
@@ -248,7 +253,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
|
||||
.hint_element = opts.hint_element,
|
||||
};
|
||||
|
||||
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint_element != null };
|
||||
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint };
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
@@ -294,17 +299,45 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
|
||||
};
|
||||
}
|
||||
|
||||
// <link rel=modulepreload href=...> — start fetching a module before import
|
||||
// resolution discovers it. Returns false when no fetch was started (the
|
||||
// module is already fetched/fetching): no event will fire on the link.
|
||||
pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
|
||||
// <link rel=modulepreload href=...> (element set) or the prescan finding a
|
||||
// <script type=module src=...> (element null) — start fetching a module
|
||||
// before import resolution discovers it. Returns false when no fetch was
|
||||
// started (the module is already fetched/fetching): no event will fire on
|
||||
// the link.
|
||||
pub fn preloadModuleHint(self: *ScriptManagerBase, element: ?*Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
|
||||
if (self.imported_modules.contains(url)) {
|
||||
return false;
|
||||
}
|
||||
try self.preloadImport(url, referrer, .{ .hint_element = element });
|
||||
try self.preloadImport(url, referrer, .{ .hint = true, .hint_element = element });
|
||||
return true;
|
||||
}
|
||||
|
||||
// A <script type=module src=...> whose URL was hinted (modulepreload link or
|
||||
// prescan)
|
||||
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
|
||||
const entry = self.imported_modules.getEntry(url) orelse return null;
|
||||
if (entry.value_ptr.hint == false) {
|
||||
// The script was preloaded, but not because of a hint. It came from v8
|
||||
// telling us to preload the module. We cannot take it here because we know
|
||||
// v8 is going to come back and wait for it.
|
||||
return null;
|
||||
}
|
||||
|
||||
const script = switch (entry.value_ptr.state) {
|
||||
.loading => |script| blk: {
|
||||
self.async_scripts.remove(&script.node);
|
||||
break :blk script;
|
||||
},
|
||||
.done => |script| script,
|
||||
// The hint's fetch failed; give the script its own attempt.
|
||||
// I'm not sure if this is the right behavior. Why would a preload fail
|
||||
// but the "real" load work? But it's definetly safer.
|
||||
.err => return null,
|
||||
};
|
||||
self.imported_modules.removeByPtr(entry.key_ptr);
|
||||
return script;
|
||||
}
|
||||
|
||||
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
|
||||
const was_evaluating = self.is_evaluating;
|
||||
self.is_evaluating = true;
|
||||
@@ -321,7 +354,7 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource
|
||||
};
|
||||
switch (entry.value_ptr.state) {
|
||||
.loading => {
|
||||
_ = try client.tick(200, .sync_wait);
|
||||
_ = try client.tickSync(200);
|
||||
continue;
|
||||
},
|
||||
.done => |script| {
|
||||
@@ -454,6 +487,7 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
.shutdown_callback = Script.shutdownCallback,
|
||||
}) catch |err| {
|
||||
self.async_scripts.remove(&script.node);
|
||||
return err;
|
||||
@@ -660,19 +694,19 @@ pub const Script = struct {
|
||||
self.manager.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
pub fn startCallback(response: HttpClient.Response) !void {
|
||||
log.debug(.http, "script fetch start", .{ .req = response });
|
||||
pub fn startCallback(transfer: *HttpClient.Transfer) !void {
|
||||
log.debug(.http, "script fetch start", .{ .req = transfer });
|
||||
}
|
||||
|
||||
pub fn headerCallback(response: HttpClient.Response) !HttpClient.HeaderResult {
|
||||
const self: *Script = @ptrCast(@alignCast(response.ctx));
|
||||
pub fn headerCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.req.ctx));
|
||||
|
||||
self.status = response.status().?;
|
||||
if (response.status() != 200) {
|
||||
self.status = transfer.responseStatus().?;
|
||||
if (transfer.responseStatus() != 200) {
|
||||
log.info(.http, "script header", .{
|
||||
.req = response,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.req = transfer,
|
||||
.status = transfer.responseStatus(),
|
||||
.content_type = transfer.contentType(),
|
||||
});
|
||||
|
||||
return .abort;
|
||||
@@ -680,64 +714,61 @@ pub const Script = struct {
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script header", .{
|
||||
.req = response,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.req = transfer,
|
||||
.status = transfer.responseStatus(),
|
||||
.content_type = transfer.contentType(),
|
||||
});
|
||||
}
|
||||
|
||||
switch (response.inner) {
|
||||
.transfer => |transfer| {
|
||||
// temp debug, trying to figure out why the next assert sometimes
|
||||
// fails. Is the buffer just corrupt or is headerCallback really
|
||||
// being called twice?
|
||||
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
|
||||
.m = @tagName(std.meta.activeTag(self.extra)),
|
||||
.a1 = self.debug_transfer_id,
|
||||
.a2 = self.debug_transfer_tries,
|
||||
.a3 = self.debug_transfer_aborted,
|
||||
.a4 = self.debug_transfer_bytes_received,
|
||||
.a5 = self.debug_transfer_notified_fail,
|
||||
.a8 = self.debug_transfer_auth_challenge,
|
||||
.a9 = self.debug_transfer_easy_id,
|
||||
.b1 = transfer.id,
|
||||
.b2 = transfer._tries,
|
||||
.b3 = transfer.state == .aborted,
|
||||
.b4 = transfer.res.bytes_received,
|
||||
.b5 = transfer._notified_fail,
|
||||
.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;
|
||||
self.debug_transfer_tries = transfer._tries;
|
||||
self.debug_transfer_aborted = transfer.state == .aborted;
|
||||
self.debug_transfer_bytes_received = transfer.res.bytes_received;
|
||||
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
|
||||
},
|
||||
else => {},
|
||||
{
|
||||
// temp debug, trying to figure out why the next assert sometimes
|
||||
// fails. Is the buffer just corrupt or is headerCallback really
|
||||
// being called twice?
|
||||
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
|
||||
.m = @tagName(std.meta.activeTag(self.extra)),
|
||||
.a1 = self.debug_transfer_id,
|
||||
.a2 = self.debug_transfer_tries,
|
||||
.a3 = self.debug_transfer_aborted,
|
||||
.a4 = self.debug_transfer_bytes_received,
|
||||
.a5 = self.debug_transfer_notified_fail,
|
||||
.a8 = self.debug_transfer_auth_challenge,
|
||||
.a9 = self.debug_transfer_easy_id,
|
||||
.b1 = transfer.id,
|
||||
.b2 = transfer._tries,
|
||||
.b3 = transfer.state == .aborted,
|
||||
.b4 = transfer.res.bytes_received,
|
||||
.b5 = transfer._notified_fail,
|
||||
.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;
|
||||
self.debug_transfer_tries = transfer._tries;
|
||||
self.debug_transfer_aborted = transfer.state == .aborted;
|
||||
self.debug_transfer_bytes_received = transfer.res.bytes_received;
|
||||
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
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, "ScriptManagerBase.Header buffer", .{ .capacity = self.source.remote.capacity });
|
||||
var buffer: std.ArrayList(u8) = .empty;
|
||||
if (response.contentLength()) |cl| {
|
||||
if (transfer.getContentLength()) |cl| {
|
||||
try buffer.ensureTotalCapacity(self.arena, cl);
|
||||
}
|
||||
self.source = .{ .remote = buffer };
|
||||
return .proceed;
|
||||
}
|
||||
|
||||
pub fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
const self: *Script = @ptrCast(@alignCast(response.ctx));
|
||||
self._dataCallback(response, data) catch |err| {
|
||||
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
|
||||
pub fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.req.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, _: HttpClient.Response, data: []const u8) !void {
|
||||
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
|
||||
try self.source.remote.appendSlice(self.arena, data);
|
||||
}
|
||||
|
||||
@@ -874,8 +905,13 @@ pub const Script = struct {
|
||||
const local = &ls.local;
|
||||
|
||||
// Per spec, trigger any microtasks BEFORE execution in case the parser
|
||||
// (e.g. via event callbacks) queued anything.
|
||||
local.runMicrotasks();
|
||||
// (e.g. via event callbacks) queued anything. Only at a microtask
|
||||
// checkpoint though (empty JS stack): a script executing because a
|
||||
// running script inserted it must not drain the queue mid-task -
|
||||
// e.g. its own insertion record must survive for takeRecords().
|
||||
if (frame.js.call_depth == 0) {
|
||||
local.runMicrotasks();
|
||||
}
|
||||
|
||||
// Handle importmap special case here: the content is a JSON containing imports.
|
||||
// Multiple <script type="importmap"> elements merge with first-wins semantics.
|
||||
@@ -937,6 +973,7 @@ pub const Script = struct {
|
||||
}
|
||||
|
||||
const caught = try_catch.caughtOrError(frame.local_arena, error.Unknown);
|
||||
lp.metrics.script_errors.incr();
|
||||
log.warn(.js, "eval script", .{
|
||||
.url = url,
|
||||
.caught = caught,
|
||||
|
||||
@@ -33,6 +33,7 @@ const Browser = @import("Browser.zig");
|
||||
pub const Runner = @import("Runner.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
const QueuedNavigation = Frame.QueuedNavigation;
|
||||
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const ArenaPool = App.ArenaPool;
|
||||
@@ -68,6 +69,11 @@ arena_pool: *ArenaPool,
|
||||
// both the live page and its in-flight replacement.
|
||||
pages: std.ArrayList(*Page) = .{},
|
||||
|
||||
// Live SharedWorkerGlobalScopes, keyed by "url\x00name", so every
|
||||
// `new SharedWorker(url, name)` in the session connects to the same instance.
|
||||
// Owned by the Page that creates it.
|
||||
shared_workers: std.StringHashMapUnmanaged(*SharedWorkerGlobalScope) = .empty,
|
||||
|
||||
_page_destruction_queue: std.ArrayList(*Page) = .{},
|
||||
|
||||
// Round-robin cursor for fair page iteration (processQueuedNavigation)
|
||||
|
||||
@@ -76,11 +76,11 @@ pub fn deinit(self: *StyleManager) void {
|
||||
self.frame.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
/// Hard cap on `@media` nesting depth. CSS Nesting allows arbitrarily-deep
|
||||
/// Hard cap on `@media` / `@layer` nesting depth. CSS allows arbitrarily-deep
|
||||
/// at-rule nesting; without a cap a hostile inline stylesheet could blow the
|
||||
/// Zig stack via mutually-recursive `applyMediaAtRule` frames. 32 is well
|
||||
/// past anything seen in the wild.
|
||||
const MAX_MEDIA_NESTING: u8 = 32;
|
||||
/// Zig stack via the mutually-recursive `applyMediaAtRule` / `applyLayerAtRule`
|
||||
/// / `applyInnerRules` frames. 32 is well past anything seen in the wild.
|
||||
const MAX_AT_RULE_NESTING: u8 = 32;
|
||||
|
||||
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
if (sheet._css_rules) |css_rules| {
|
||||
@@ -91,6 +91,9 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
// `insertRule` / `replaceSync` participates in the cascade
|
||||
// when its query matches the viewport.
|
||||
.media => try self.applyMediaAtRule(rule._text, 0),
|
||||
// `@layer` blocks flatten into the cascade (see
|
||||
// applyLayerAtRule for the deliberate scope).
|
||||
.layer => try self.applyLayerAtRule(rule._text, 0),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
@@ -105,12 +108,15 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
switch (parsed_rule) {
|
||||
.style => |s| try self.addRawRule(s.selector, s.block),
|
||||
.at_rule => |a| {
|
||||
// Only `@media` participates in the cascade here. Other
|
||||
// at-rules (`@keyframes`, `@supports`, `@font-face`, …)
|
||||
// don't carry top-level declarations relevant to the
|
||||
// visibility filter and stay skipped as before.
|
||||
// Only `@media` and `@layer` participate in the cascade
|
||||
// here. Other at-rules (`@keyframes`, `@supports`,
|
||||
// `@font-face`, …) don't carry top-level declarations
|
||||
// relevant to the visibility filter and stay skipped as
|
||||
// before.
|
||||
if (std.ascii.eqlIgnoreCase(a.keyword, "media")) {
|
||||
try self.applyMediaAtRule(a.text, 0);
|
||||
} else if (std.ascii.eqlIgnoreCase(a.keyword, "layer")) {
|
||||
try self.applyLayerAtRule(a.text, 0);
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -123,45 +129,74 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
/// lived at the top level. Non-matching queries silently drop the inner
|
||||
/// rules. Inline-only by design: external `<link rel="stylesheet">` is out
|
||||
/// of scope for the headless engine.
|
||||
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) !void {
|
||||
if (depth >= MAX_MEDIA_NESTING) return;
|
||||
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) return;
|
||||
|
||||
// text shape: `@media <query> { <inner> }` for well-formed input.
|
||||
// `CssParser.RulesIterator.consumeAtRule` always emits a span starting
|
||||
// at `@`; for unclosed blocks it runs to EOF, so the closing `}` is
|
||||
// located explicitly rather than assumed to be the final byte.
|
||||
|
||||
if (text.len < @as(usize, "@media".len) + 2) return;
|
||||
if (!std.ascii.startsWithIgnoreCase(text, "@media")) return;
|
||||
|
||||
const rest = text["@media".len..];
|
||||
// Use a comment-aware brace finder; a `/* { */` in the prelude would
|
||||
// otherwise split the rule at the wrong place. The inner block's
|
||||
// contents are re-parsed by CssParser below, which has its own trivia
|
||||
// handling, so only this outer boundary needs the special-case scan.
|
||||
const open = indexOfOpenBraceSkippingComments(rest) orelse return;
|
||||
// Search only past the opening brace — the matching `}` lives there, and
|
||||
// any returned position is naturally `> open` (since `rest[open] == '{'`).
|
||||
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return);
|
||||
|
||||
const query = std.mem.trim(u8, rest[0..open], &std.ascii.whitespace);
|
||||
const inner = rest[open + 1 .. close];
|
||||
const block = atRuleBlock(text, "@media") orelse return;
|
||||
const query = std.mem.trim(u8, block.prelude, &std.ascii.whitespace);
|
||||
|
||||
if (!MediaQuery.matches(query, self.frame._page.getViewport())) return;
|
||||
|
||||
try self.applyInnerRules(block.body, depth + 1);
|
||||
}
|
||||
|
||||
/// Apply an `@layer` block rule by parsing its inner block into the cascade
|
||||
/// as if its rules lived at the top level, recursing into nested `@media` /
|
||||
/// `@layer`. Cascade-layer priority ordering (css-cascade-5 §6.4) is
|
||||
/// deliberately not implemented: layers are flattened and ties keep breaking
|
||||
/// on specificity + document order. Handles named blocks (including dotted
|
||||
/// sub-layer names like `@layer theme.dark`) and anonymous blocks; the
|
||||
/// statement form (`@layer a, b;`) declares ordering only and carries no
|
||||
/// block (`atRuleBlock` returns null), so it applies nothing.
|
||||
fn applyLayerAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
|
||||
if (depth >= MAX_AT_RULE_NESTING) return;
|
||||
|
||||
// The layer-name prelude is intentionally ignored — layers are flattened.
|
||||
const block = atRuleBlock(text, "@layer") orelse return;
|
||||
try self.applyInnerRules(block.body, depth + 1);
|
||||
}
|
||||
|
||||
/// Parse a conditional at-rule's inner block, adding style rules to the
|
||||
/// cascade and recursing into nested `@media` / `@layer`. `depth` is the
|
||||
/// nesting depth of the *nested* at-rules (callers pass their own depth + 1).
|
||||
fn applyInnerRules(self: *StyleManager, inner: []const u8, depth: u8) Allocator.Error!void {
|
||||
var it = CssParser.parseStylesheet(inner);
|
||||
while (it.next()) |nested_rule| {
|
||||
switch (nested_rule) {
|
||||
.style => |s| try self.addRawRule(s.selector, s.block),
|
||||
.at_rule => |nested| {
|
||||
if (std.ascii.eqlIgnoreCase(nested.keyword, "media")) {
|
||||
try self.applyMediaAtRule(nested.text, depth + 1);
|
||||
try self.applyMediaAtRule(nested.text, depth);
|
||||
} else if (std.ascii.eqlIgnoreCase(nested.keyword, "layer")) {
|
||||
try self.applyLayerAtRule(nested.text, depth);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a block at-rule into its prelude and inner block: for
|
||||
/// `@media <query> { <body> }` returns `.{ .prelude = "<query>", .body = "<body>" }`.
|
||||
/// Returns `null` when `text` doesn't start with `keyword`, or when it carries
|
||||
/// no block — notably the statement form (`@layer a, b;`), which declares
|
||||
/// ordering only.
|
||||
///
|
||||
/// `CssParser.RulesIterator.consumeAtRule` always emits a span starting at `@`;
|
||||
/// for unclosed blocks it runs to EOF, so the closing `}` is located explicitly
|
||||
/// rather than assumed to be the final byte. The opening `{` is found with a
|
||||
/// comment-aware scan so a `/* { */` in the prelude doesn't split the rule at
|
||||
/// the wrong place; the block's own trivia is handled by the CssParser re-parse
|
||||
/// in `applyInnerRules`, so only this outer boundary needs the special-case scan.
|
||||
fn atRuleBlock(text: []const u8, keyword: []const u8) ?struct { prelude: []const u8, body: []const u8 } {
|
||||
if (!std.ascii.startsWithIgnoreCase(text, keyword)) return null;
|
||||
const rest = text[keyword.len..];
|
||||
const open = indexOfOpenBraceSkippingComments(rest) orelse return null;
|
||||
// Search only past the opening brace — the matching `}` lives there, and
|
||||
// any returned position is naturally `> open` (since `rest[open] == '{'`).
|
||||
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return null);
|
||||
return .{ .prelude = rest[0..open], .body = rest[open + 1 .. close] };
|
||||
}
|
||||
|
||||
/// Find the first `{` in `s` that is not inside a CSS `/* ... */` comment.
|
||||
/// An unclosed comment returns `null` (treat the whole rule as malformed).
|
||||
fn indexOfOpenBraceSkippingComments(s: []const u8) ?usize {
|
||||
|
||||
@@ -829,6 +829,12 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
|
||||
._definition = definition,
|
||||
});
|
||||
|
||||
// Fragment-parse context element. It will not be inserted and
|
||||
// we should not run the custom element's constructor.
|
||||
if (frame._skip_custom_element_upgrade) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const def = definition orelse {
|
||||
const element = node.as(Element);
|
||||
const custom = element.is(Element.Html.Custom).?;
|
||||
@@ -879,16 +885,47 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
|
||||
return createHtmlElementT(frame, Element.Html.Unknown, namespace, attribute_iterator, .{ ._proto = undefined, ._tag_name = tag_name });
|
||||
},
|
||||
.svg => {
|
||||
const tag_name = try String.init(frame.arena, name, .{});
|
||||
if (std.ascii.eqlIgnoreCase(name, "svg")) {
|
||||
return createSvgElementT(frame, Element.Svg, name, attribute_iterator, .{
|
||||
._proto = undefined,
|
||||
._type = .svg,
|
||||
._tag_name = tag_name,
|
||||
});
|
||||
const Graphics = Element.Svg.Graphics;
|
||||
const Geometry = Graphics.Geometry;
|
||||
// SVG tag names are case-sensitive; no lowering before matching.
|
||||
switch (name.len) {
|
||||
1 => switch (name[0]) {
|
||||
'g' => return createSvgGraphicsElementT(frame, Graphics.G, name, attribute_iterator),
|
||||
'a' => return createSvgGraphicsElementT(frame, Graphics.A, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
3 => switch (@as(u24, @bitCast(name[0..3].*))) {
|
||||
asUint("svg") => return createSvgGraphicsElementT(frame, Graphics.Svg, name, attribute_iterator),
|
||||
asUint("use") => return createSvgGraphicsElementT(frame, Graphics.Use, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
4 => switch (@as(u32, @bitCast(name[0..4].*))) {
|
||||
asUint("defs") => return createSvgGraphicsElementT(frame, Graphics.Defs, name, attribute_iterator),
|
||||
asUint("rect") => return createSvgGeometryElementT(frame, Geometry.Rect, name, attribute_iterator),
|
||||
asUint("line") => return createSvgGeometryElementT(frame, Geometry.Line, name, attribute_iterator),
|
||||
asUint("path") => return createSvgGeometryElementT(frame, Geometry.Path, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
5 => switch (@as(u40, @bitCast(name[0..5].*))) {
|
||||
asUint("image") => return createSvgGraphicsElementT(frame, Graphics.Image, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
6 => switch (@as(u48, @bitCast(name[0..6].*))) {
|
||||
asUint("circle") => return createSvgGeometryElementT(frame, Geometry.Circle, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
7 => switch (@as(u56, @bitCast(name[0..7].*))) {
|
||||
asUint("ellipse") => return createSvgGeometryElementT(frame, Geometry.Ellipse, name, attribute_iterator),
|
||||
asUint("polygon") => return createSvgGeometryElementT(frame, Geometry.Polygon, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
8 => switch (@as(u64, @bitCast(name[0..8].*))) {
|
||||
asUint("polyline") => return createSvgGeometryElementT(frame, Geometry.Polyline, name, attribute_iterator),
|
||||
else => {},
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// Other SVG elements (rect, circle, text, g, etc.)
|
||||
const lower = std.ascii.lowerString(&frame.buf, name);
|
||||
const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown;
|
||||
return createSvgElementT(frame, Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag });
|
||||
@@ -930,7 +967,20 @@ fn createHtmlMediaElementT(frame: *Frame, comptime E: type, namespace: Element.N
|
||||
|
||||
fn createSvgElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype, svg_element: E) !*Node {
|
||||
const svg_element_ptr = try frame._factory.svgElement(tag_name, svg_element);
|
||||
var element = svg_element_ptr.asElement();
|
||||
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
|
||||
}
|
||||
|
||||
fn createSvgGraphicsElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype) !*Node {
|
||||
const svg_element_ptr = try frame._factory.svgGraphicsElement(tag_name, E{ ._proto = undefined });
|
||||
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
|
||||
}
|
||||
|
||||
fn createSvgGeometryElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype) !*Node {
|
||||
const svg_element_ptr = try frame._factory.svgGeometryElement(tag_name, E{ ._proto = undefined });
|
||||
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
|
||||
}
|
||||
|
||||
fn initSvgElement(frame: *Frame, element: *Element, attribute_iterator: anytype) !*Node {
|
||||
element._namespace = .svg;
|
||||
element._attributes.normalize = false;
|
||||
try populateElementAttributes(frame, element, attribute_iterator);
|
||||
|
||||
196
src/browser/frame/preload.zig
Normal file
196
src/browser/frame/preload.zig
Normal file
@@ -0,0 +1,196 @@
|
||||
// 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/>.
|
||||
|
||||
// Speculative script fetching: <link rel=preload/modulepreload> hints and the
|
||||
// pre-parse scan of the raw HTML. All of it only starts downloads early —
|
||||
// consumption happens in ScriptManager / ScriptManagerBase when the real
|
||||
// <script> element or import shows up.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const URL = @import("../URL.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
const Parser = @import("../parser/Parser.zig");
|
||||
const Element = @import("../webapi/Element.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// start prefetching <link rel="preload" as="script" href=...>`. element is the
|
||||
// hint <link> to fire load/error on, null when the hint came from the prescan.
|
||||
pub fn scriptHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
|
||||
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const arena = frame.getArena(.small, "preload.scriptHint") catch return false;
|
||||
defer frame.releaseArena(arena);
|
||||
|
||||
const resolved = URL.resolve(arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
|
||||
if (!isRemoteScheme(resolved)) {
|
||||
return false;
|
||||
}
|
||||
return frame._script_manager.preloadScript(element, resolved) catch false;
|
||||
}
|
||||
|
||||
// start prefetching <link rel="modulepreload" href=...>. element is the hint
|
||||
// <link> to fire load/error on, null when the hint came from the prescan.
|
||||
pub fn moduleHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
|
||||
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasNonRemoteScheme(href)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The url becomes the imported_modules key, which must outlive the fetch
|
||||
// so it lives on the frame arena
|
||||
const resolved = URL.resolve(frame.arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
|
||||
if (!isRemoteScheme(resolved)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return frame._script_manager.base.preloadModuleHint(element, resolved, frame.url) catch false;
|
||||
}
|
||||
|
||||
// Scan the HTML for <script src=...> before parsing so that we can start
|
||||
// fetching them ASAP. The downside is we might accidentally fetch more than we
|
||||
// should, but the upside can be a pretty significant performance improvement.
|
||||
// Without this, N large blocking <script src=...> tags are downloaded serially.
|
||||
// This essentially does the same thing as the <link preload/preloadModule> but
|
||||
// without needing anything special from the HTML.
|
||||
pub fn prescan(frame: *Frame, html: []const u8) void {
|
||||
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
|
||||
return;
|
||||
}
|
||||
const arena = frame.getArena(.small, "preload.prescan") catch return;
|
||||
defer frame.releaseArena(arena);
|
||||
|
||||
var scan = Prescan{ .frame = frame, .base = frame.base(), .arena = arena };
|
||||
Parser.prescan(html, frame.charset, &scan, Prescan.callback);
|
||||
}
|
||||
|
||||
const Prescan = struct {
|
||||
frame: *Frame,
|
||||
arena: Allocator,
|
||||
base: [:0]const u8,
|
||||
|
||||
fn callback(ctx: *anyopaque, kind: Parser.PrescanResource, url_ptr: [*c]const u8, url_len: usize) callconv(.c) void {
|
||||
const self: *Prescan = @ptrCast(@alignCast(ctx));
|
||||
if (url_len == 0) {
|
||||
return;
|
||||
}
|
||||
const href = url_ptr[0..url_len];
|
||||
const frame = self.frame;
|
||||
switch (kind) {
|
||||
.base => {
|
||||
self.base = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
|
||||
},
|
||||
.script => {
|
||||
if (hasNonRemoteScheme(href)) {
|
||||
return;
|
||||
}
|
||||
const resolved = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
|
||||
if (isRemoteScheme(resolved) == false) {
|
||||
return;
|
||||
}
|
||||
_ = frame._script_manager.preloadScript(null, resolved) catch {};
|
||||
},
|
||||
.module => {
|
||||
if (hasNonRemoteScheme(href)) {
|
||||
return;
|
||||
}
|
||||
// The url becomes the imported_modules key, which must
|
||||
// outlive the fetch so it lives on the frame arena.
|
||||
const resolved = URL.resolve(frame.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
|
||||
if (isRemoteScheme(resolved) == false) {
|
||||
return;
|
||||
}
|
||||
_ = frame._script_manager.base.preloadModuleHint(null, resolved, frame.url) catch {};
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn isRemoteScheme(url: []const u8) bool {
|
||||
return std.ascii.startsWithIgnoreCase(url, "http:") or std.ascii.startsWithIgnoreCase(url, "https:");
|
||||
}
|
||||
|
||||
// Non-http(s) scheme (e.g. data:, blob:) never resolve to a remote URL. We
|
||||
// detect this upfront to prevent uncessary URL.resolves that would dupe the
|
||||
// (potentially very lage) data.
|
||||
fn hasNonRemoteScheme(href: []const u8) bool {
|
||||
if (isRemoteScheme(href)) {
|
||||
return false;
|
||||
}
|
||||
if (href.len == 0 or !std.ascii.isAlphabetic(href[0])) {
|
||||
return false;
|
||||
}
|
||||
for (href[1..]) |c| {
|
||||
switch (c) {
|
||||
':' => return true,
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '+', '-', '.' => {},
|
||||
else => return false,
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "preload: prescan" {
|
||||
defer testing.reset();
|
||||
|
||||
const page = try testing.pageTest("mcp_nav.html", .{});
|
||||
defer page.close();
|
||||
const frame = page.frame().?;
|
||||
|
||||
// The fetches this starts stay in flight (nothing ticks the client before
|
||||
// page.close aborts them), so the maps hold exactly what the scan found.
|
||||
// All srcs sit under /serve-count/ because the test server panics on
|
||||
// unknown paths elsewhere; unknown names there get a plain 404.
|
||||
prescan(frame,
|
||||
\\<html><head>
|
||||
\\<script src="/serve-count/unit_a.js"></script>
|
||||
\\<script src="/serve-count/unit_a.js"></script>
|
||||
\\<script type="module" src="/serve-count/unit_m.js"></script>
|
||||
\\<script src="/serve-count/unit_skipped.js" nomodule></script>
|
||||
\\<script type="application/json" src="/serve-count/unit_data.js"></script>
|
||||
\\<script src="data:text/javascript,window.unit_data_url = 1;"></script>
|
||||
\\<script type="module" src="data:text/javascript,window.unit_data_module = 1;"></script>
|
||||
\\<script>var trap = "</scr" + "ipt><script src=/serve-count/unit_inline.js>";</script>
|
||||
\\<!-- <script src="/serve-count/unit_commented.js"></script> -->
|
||||
\\<template><script src="/serve-count/unit_templated.js"></script></template>
|
||||
\\<noscript><script src="/serve-count/unit_noscripted.js"></script></noscript>
|
||||
\\<style>p:before { content: "<script src=/serve-count/unit_styled.js></script>" }</style>
|
||||
\\<base href="/serve-count/sub/">
|
||||
\\<base href="/serve-count/ignored/">
|
||||
\\<script src="based.js"></script>
|
||||
\\</head><body></body></html>
|
||||
);
|
||||
|
||||
const sm = &frame._script_manager;
|
||||
try testing.expectEqual(2, sm.preloaded_scripts.count());
|
||||
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/unit_a.js"));
|
||||
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/sub/based.js"));
|
||||
|
||||
try testing.expectEqual(1, sm.base.imported_modules.count());
|
||||
const module = sm.base.imported_modules.get("http://127.0.0.1:9582/serve-count/unit_m.js") orelse return error.MissingModule;
|
||||
try testing.expectEqual(true, module.hint);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const Frame = @import("../Frame.zig");
|
||||
const js = @import("../js/js.zig");
|
||||
|
||||
const Node = @import("../webapi/Node.zig");
|
||||
const Event = @import("../webapi/Event.zig");
|
||||
@@ -78,6 +79,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
|
||||
});
|
||||
}
|
||||
try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0);
|
||||
try focusEditingHostForMouseDown(frame, target);
|
||||
}
|
||||
|
||||
pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void {
|
||||
@@ -200,6 +202,128 @@ fn deltaToScroll(d: f64) i32 {
|
||||
}
|
||||
|
||||
// callback when the "click" event reaches the frame.
|
||||
// Whether the element has a click activation behavior that handleClick
|
||||
// implements.
|
||||
fn hasClickActivationBehavior(node: *Node) bool {
|
||||
const element = node.is(Element) orelse return false;
|
||||
const html_element = element.is(Element.Html) orelse return false;
|
||||
return switch (html_element._type) {
|
||||
.anchor => element.getAttributeSafe(comptime .wrap("href")) != null,
|
||||
.input, .button, .select, .textarea, .label => true,
|
||||
.generic => |generic| generic._tag == .summary,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
// Clicks on editable content are for editing: they don't activate the
|
||||
// element or any enclosing link.
|
||||
// "contenteditable" is 15 bytes — past the comptime SSO limit — so the
|
||||
// String wrap runs at runtime, mirroring Html.getIsContentEditable.
|
||||
fn isEditingHost(node: *Node) bool {
|
||||
const element = node.is(Element) orelse return false;
|
||||
const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false;
|
||||
return std.ascii.eqlIgnoreCase(value, "false") == false;
|
||||
}
|
||||
|
||||
// A mousedown on editable content focuses its editing host: the outermost
|
||||
// element of the contiguous editable chain containing the target.
|
||||
pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void {
|
||||
var node: ?*Node = target.asNode();
|
||||
var editable: ?*Node = null;
|
||||
while (node) |n| : (node = n._parent) {
|
||||
if (isEditingHost(n)) {
|
||||
editable = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var host = editable orelse return;
|
||||
while (host._parent) |p| {
|
||||
if (!isEditingHost(p)) {
|
||||
break;
|
||||
}
|
||||
host = p;
|
||||
}
|
||||
const host_element = host.is(Element) orelse return;
|
||||
try host_element.focus(frame);
|
||||
}
|
||||
|
||||
// Per the DOM dispatch algorithm, a click's activation target is the event
|
||||
// target itself when it has activation behavior, otherwise — for bubbling
|
||||
// events only — the nearest ancestor that has one.
|
||||
pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node {
|
||||
if (isEditingHost(target)) {
|
||||
return null;
|
||||
}
|
||||
if (hasClickActivationBehavior(target)) {
|
||||
return target;
|
||||
}
|
||||
if (!bubbles) {
|
||||
return null;
|
||||
}
|
||||
var node = target._parent;
|
||||
while (node) |n| : (node = n._parent) {
|
||||
if (isEditingHost(n)) {
|
||||
return null;
|
||||
}
|
||||
if (hasClickActivationBehavior(n)) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn runJavascriptUrl(frame: *Frame, source: []const u8) !void {
|
||||
const arena = try frame.getArena(.tiny, "javascript-url");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
const task = try arena.create(JavascriptUrlTask);
|
||||
task.* = .{
|
||||
.frame = frame,
|
||||
.arena = arena,
|
||||
// TODO: the URL body should be percent-decoded; hrefs written in
|
||||
// markup rarely are.
|
||||
.source = try arena.dupe(u8, source),
|
||||
};
|
||||
try frame.js.scheduler.add(task, JavascriptUrlTask.run, 0, .{
|
||||
.name = "javascript-url",
|
||||
.finalizer = JavascriptUrlTask.finalize,
|
||||
});
|
||||
}
|
||||
|
||||
const JavascriptUrlTask = struct {
|
||||
frame: *Frame,
|
||||
arena: std.mem.Allocator,
|
||||
source: []const u8,
|
||||
|
||||
fn run(ptr: *anyopaque) !?u32 {
|
||||
const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr));
|
||||
const frame = self.frame;
|
||||
defer self.deinit();
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const script = ls.local.compile(self.source, "javascript:") catch |err| {
|
||||
log.warn(.browser, "javascript-url compile", .{ .err = err, .type = frame._type, .url = frame.url });
|
||||
return null;
|
||||
};
|
||||
_ = script.run() catch |err| {
|
||||
log.warn(.browser, "javascript-url run", .{ .err = err, .type = frame._type, .url = frame.url });
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
fn finalize(ptr: *anyopaque) void {
|
||||
const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr));
|
||||
self.deinit();
|
||||
}
|
||||
|
||||
fn deinit(self: *JavascriptUrlTask) void {
|
||||
self.frame.releaseArena(self.arena);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn handleClick(frame: *Frame, target: *Node) !void {
|
||||
// TODO: Also support <area> elements when implement
|
||||
const element = target.is(Element) orelse return;
|
||||
@@ -213,7 +337,10 @@ pub fn handleClick(frame: *Frame, target: *Node) !void {
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, href, "javascript:")) {
|
||||
return;
|
||||
// Navigating to a javascript: URL evaluates the script in the
|
||||
// node's frame as a queued task. (A string completion value
|
||||
// would replace the document; we ignore results.)
|
||||
return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]);
|
||||
}
|
||||
|
||||
if (try element.hasAttribute(comptime .wrap("download"), frame)) {
|
||||
|
||||
@@ -448,7 +448,7 @@ pub fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
|
||||
}
|
||||
if (child.is(Node.CData)) |cdata| {
|
||||
if (cdata.is(Node.CData.Text)) |text| {
|
||||
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
|
||||
const content = std.mem.trim(u8, text.ownData(), &std.ascii.whitespace);
|
||||
if (content.len > 0) {
|
||||
if (single_chunk == null and arr.items.len == 0) {
|
||||
single_chunk = content;
|
||||
|
||||
@@ -209,59 +209,6 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf
|
||||
return handleIndexedReturn(T, F, true, local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
hs.init(local.isolate);
|
||||
defer hs.deinit();
|
||||
|
||||
const info = PropertyCallbackInfo{ .handle = handle };
|
||||
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
|
||||
handleError(T, @TypeOf(func), local, err, info);
|
||||
return js.Intercepted.no;
|
||||
};
|
||||
}
|
||||
|
||||
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
const F = @TypeOf(func);
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@field(args, "1") = idx;
|
||||
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
|
||||
if (@typeInfo(F).@"fn".params.len == 4) {
|
||||
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn deleteIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
hs.init(local.isolate);
|
||||
defer hs.deinit();
|
||||
|
||||
const info = PropertyCallbackInfo{ .handle = handle };
|
||||
return _deleteIndex(T, local, func, idx, info, opts) catch |err| {
|
||||
handleError(T, @TypeOf(func), local, err, info);
|
||||
return js.Intercepted.no;
|
||||
};
|
||||
}
|
||||
|
||||
fn _deleteIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
const F = @TypeOf(func);
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@field(args, "1") = idx;
|
||||
if (@typeInfo(F).@"fn".params.len == 3) {
|
||||
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
@@ -288,6 +235,59 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
|
||||
return handleIndexedReturn(T, F, true, local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
hs.init(local.isolate);
|
||||
defer hs.deinit();
|
||||
|
||||
const info = PropertyCallbackInfo{ .handle = handle };
|
||||
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
|
||||
handleError(T, @TypeOf(func), local, err, info);
|
||||
return js.Intercepted.no;
|
||||
};
|
||||
}
|
||||
|
||||
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
const F = @TypeOf(func);
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@field(args, "1") = idx;
|
||||
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
|
||||
if (@typeInfo(F).@"fn".params.len == 4) {
|
||||
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn deleteOrDefineIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
hs.init(local.isolate);
|
||||
defer hs.deinit();
|
||||
|
||||
const info = PropertyCallbackInfo{ .handle = handle };
|
||||
return _deleteOrDefineIndex(T, local, func, idx, info, opts) catch |err| {
|
||||
handleError(T, @TypeOf(func), local, err, info);
|
||||
return js.Intercepted.no;
|
||||
};
|
||||
}
|
||||
|
||||
fn _deleteOrDefineIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
const F = @TypeOf(func);
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@field(args, "1") = idx;
|
||||
if (@typeInfo(F).@"fn".params.len == 3) {
|
||||
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
@@ -312,10 +312,10 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
|
||||
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
||||
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
pub fn deleteOrDefineNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
const local = &self.local;
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
@@ -323,13 +323,13 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c
|
||||
defer hs.deinit();
|
||||
|
||||
const info = PropertyCallbackInfo{ .handle = handle };
|
||||
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
|
||||
return _deleteOrDefineNamedIndex(T, local, func, name, info, opts) catch |err| {
|
||||
handleError(T, @TypeOf(func), local, err, info);
|
||||
return js.Intercepted.no;
|
||||
};
|
||||
}
|
||||
|
||||
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
fn _deleteOrDefineNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
|
||||
const F = @TypeOf(func);
|
||||
var args: ParameterTypes(F) = undefined;
|
||||
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
|
||||
@@ -338,7 +338,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
|
||||
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
|
||||
}
|
||||
const ret = @call(.auto, func, args);
|
||||
return handleIndexedReturn(T, F, false, local, ret, info, opts);
|
||||
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
|
||||
}
|
||||
|
||||
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
|
||||
@@ -388,11 +388,7 @@ fn _getIndexQuery(comptime T: type, local: *const Local, func: anytype, idx: u32
|
||||
if (@typeInfo(F).@"fn".params.len == 3) {
|
||||
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
|
||||
}
|
||||
if (@call(.auto, func, args) == false) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
|
||||
return js.Intercepted.yes;
|
||||
return queryReturn(local, @call(.auto, func, args), info);
|
||||
}
|
||||
|
||||
pub fn getNamedQuery(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo) u32 {
|
||||
@@ -417,12 +413,33 @@ fn _getNamedQuery(comptime T: type, local: *const Local, func: anytype, name: *c
|
||||
if (@typeInfo(F).@"fn".params.len == 3) {
|
||||
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
|
||||
}
|
||||
if (@call(.auto, func, args) == false) {
|
||||
return js.Intercepted.no;
|
||||
return queryReturn(local, @call(.auto, func, args), info);
|
||||
}
|
||||
|
||||
// A query callback either returns a bool (true -> the property exists as an
|
||||
// enumerable, writable, configurable data property, PropertyAttribute.None)
|
||||
// or the v8.PropertyAttribute bits directly (e.g. v8.ReadOnly).
|
||||
// error.NotHandled falls through to the ordinary property lookup.
|
||||
fn queryReturn(local: *const Local, ret: anytype, info: PropertyCallbackInfo) !u32 {
|
||||
const val = switch (@typeInfo(@TypeOf(ret))) {
|
||||
.error_union => |eu| ret catch |err| {
|
||||
if (comptime isInErrorSet(error.NotHandled, eu.error_set)) {
|
||||
if (err == error.NotHandled) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
}
|
||||
return err;
|
||||
},
|
||||
else => ret,
|
||||
};
|
||||
if (@TypeOf(val) == bool) {
|
||||
if (val == false) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
|
||||
} else {
|
||||
info.getReturnValue().set(try local.zigValueToJs(@as(u32, val), .{}));
|
||||
}
|
||||
// The property exists as a supported property name; report it as an
|
||||
// enumerable, writable, configurable data property (PropertyAttribute.None).
|
||||
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
|
||||
return js.Intercepted.yes;
|
||||
}
|
||||
|
||||
@@ -453,6 +470,18 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
|
||||
return js.Intercepted.yes;
|
||||
}
|
||||
|
||||
// Setter/deleter interceptors normally return void: intercepting is enough
|
||||
// to mark the operation successful. When they return a bool instead, it is
|
||||
// forwarded as the v8 return value; false marks the operation as failed,
|
||||
// which makes v8 throw a TypeError in strict mode.
|
||||
fn returnsBool(comptime F: type) bool {
|
||||
const RT = @typeInfo(F).@"fn".return_type.?;
|
||||
return switch (@typeInfo(RT)) {
|
||||
.error_union => |eu| eu.payload == bool,
|
||||
else => RT == bool,
|
||||
};
|
||||
}
|
||||
|
||||
fn isInErrorSet(err: anyerror, comptime T: type) bool {
|
||||
inline for (@typeInfo(T).error_set.?) |e| {
|
||||
if (err == @field(anyerror, e.name)) return true;
|
||||
@@ -471,6 +500,65 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T
|
||||
return try js.String.toSlice(.{ .local = local, .handle = handle });
|
||||
}
|
||||
|
||||
// Per Web IDL, exceptions belong to the operation's relevant realm — the
|
||||
// receiver's — which differs from the calling realm for cross-realm calls
|
||||
// (v8 API callbacks run in the caller's context, and our JS wrappers are
|
||||
// shared across the page's contexts). For DOM nodes, the relevant realm is
|
||||
// the node document's frame; otherwise fall back to the calling realm.
|
||||
fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local {
|
||||
if (@TypeOf(info) != FunctionCallbackInfo) {
|
||||
return local.*;
|
||||
}
|
||||
|
||||
const frame = switch (local.ctx.global) {
|
||||
.frame => |f| f,
|
||||
.worker => return local.*,
|
||||
};
|
||||
|
||||
const Node = @import("../webapi/Node.zig");
|
||||
const Document = @import("../webapi/Document.zig");
|
||||
|
||||
const is_node_type = comptime blk: {
|
||||
if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "JsApi")) break :blk false;
|
||||
break :blk @import("bridge.zig").inheritsOrIs(T.JsApi, Node.JsApi);
|
||||
};
|
||||
if (comptime !is_node_type) {
|
||||
return local.*;
|
||||
}
|
||||
|
||||
const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*;
|
||||
const node = protoNode(T, instance);
|
||||
|
||||
const doc: *Document = node.ownerDocument(frame) orelse switch (node._type) {
|
||||
.document => |d| d,
|
||||
else => return local.*,
|
||||
};
|
||||
const doc_frame = doc._frame orelse return local.*;
|
||||
if (doc_frame == frame) {
|
||||
return local.*;
|
||||
}
|
||||
|
||||
const ctx = doc_frame.js;
|
||||
const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle) orelse return local.*);
|
||||
return .{
|
||||
.ctx = ctx,
|
||||
.handle = local_v8_context,
|
||||
.call_arena = ctx.call_arena,
|
||||
.isolate = ctx.isolate,
|
||||
};
|
||||
}
|
||||
|
||||
// Upcast a Node-descendant instance to *Node by walking the _proto chain.
|
||||
// Not every node type defines an asNode() helper (e.g. Comment, Text), but
|
||||
// inheritsOrIs guarantees Node is in the chain
|
||||
fn protoNode(comptime T: type, instance: *T) *@import("../webapi/Node.zig") {
|
||||
if (T == @import("../webapi/Node.zig")) {
|
||||
return instance;
|
||||
}
|
||||
const Proto = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child;
|
||||
return protoNode(Proto, instance._proto);
|
||||
}
|
||||
|
||||
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void {
|
||||
const isolate = local.isolate;
|
||||
|
||||
@@ -484,14 +572,34 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
|
||||
}
|
||||
}
|
||||
|
||||
const js_err: *const v8.Value = switch (err) {
|
||||
// early exit
|
||||
switch (err) {
|
||||
error.TryCatchRethrow => return,
|
||||
error.InvalidArgument => isolate.createTypeError("invalid argument"),
|
||||
error.TypeError => isolate.createTypeError(""),
|
||||
error.RangeError => isolate.createRangeError(""),
|
||||
error.OutOfMemory => isolate.createError("out of memory"),
|
||||
error.IllegalConstructor => isolate.createError("Illegal Constructor"),
|
||||
else => domExceptionToJs(local, err) orelse isolate.createError(@errorName(err)),
|
||||
// A JS exception is already pending in the isolate (e.g. a value's
|
||||
// toString threw during argument conversion); throwing anything here
|
||||
// would replace the original exception the script expects to see.
|
||||
error.JsException => return,
|
||||
else => {},
|
||||
}
|
||||
|
||||
const err_local = errorLocal(T, local, info);
|
||||
|
||||
const js_err: *const v8.Value = blk: {
|
||||
// Error constructors use the isolate's current context: enter the
|
||||
// receiver's realm so the exception gets its prototypes.
|
||||
const entered = err_local.ctx != local.ctx;
|
||||
if (entered) v8.v8__Context__Enter(err_local.handle);
|
||||
defer if (entered) v8.v8__Context__Exit(err_local.handle);
|
||||
|
||||
break :blk switch (err) {
|
||||
error.InvalidArgument => isolate.createTypeError("invalid argument"),
|
||||
error.TypeError => isolate.createTypeError(""),
|
||||
error.RangeError => isolate.createRangeError(""),
|
||||
error.OutOfMemory => isolate.createError("out of memory"),
|
||||
error.IllegalConstructor => isolate.createError("Illegal Constructor"),
|
||||
error.TryCatchRethrow, error.JsException => unreachable, // early exited a few lines up
|
||||
else => domExceptionToJs(&err_local, err) orelse isolate.createError(@errorName(err)),
|
||||
};
|
||||
};
|
||||
|
||||
const js_exception = isolate.throwException(js_err);
|
||||
@@ -686,6 +794,7 @@ pub const Function = struct {
|
||||
exposed: Exposed = .both,
|
||||
ce_reactions: bool = false,
|
||||
js_name: ?[:0]const u8 = null,
|
||||
unforgeable: bool = false,
|
||||
|
||||
pub const Exposed = enum { both, window, worker };
|
||||
|
||||
@@ -987,6 +1096,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
|
||||
// type instantiation of jsValueToZig may not include such errors
|
||||
// in its inferred error set.
|
||||
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch |err| {
|
||||
if (err == error.JsException) {
|
||||
// an exception thrown by user code (e.g. a toString
|
||||
// getter) is pending; propagate it untouched
|
||||
return err;
|
||||
}
|
||||
const DOMException = @import("../webapi/DOMException.zig");
|
||||
if (DOMException.fromError(err) != null) {
|
||||
return err;
|
||||
|
||||
@@ -277,14 +277,6 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
|
||||
return self.page.globals.append(self.page.frame_arena, global);
|
||||
}
|
||||
|
||||
pub fn trackTemp(self: *Context, global: v8.Global) !void {
|
||||
return self.page.temps.put(self.page.frame_arena, global.data_ptr, global);
|
||||
}
|
||||
|
||||
pub const IdentityResult = struct {
|
||||
value_ptr: *v8.Global,
|
||||
found_existing: bool,
|
||||
@@ -1134,7 +1126,7 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
|
||||
// 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
|
||||
// wrapper for these callbacks which references a js.Function, 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
|
||||
|
||||
@@ -31,6 +31,7 @@ const App = @import("../../App.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
const Window = @import("../webapi/Window.zig");
|
||||
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
|
||||
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
|
||||
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
@@ -274,8 +275,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
|
||||
break :blk @ptrCast(v8.v8__Global__Get(existing, isolate.handle));
|
||||
};
|
||||
|
||||
// Restore the context from the snapshot (0 = Page, 1 = Worker)
|
||||
const snapshot_index: u32 = if (comptime is_frame) 0 else 1;
|
||||
// Restore the context from the snapshot
|
||||
// (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker)
|
||||
const snapshot_index: u32 = if (comptime is_frame) 0 else switch (global._type) {
|
||||
.dedicated => 1,
|
||||
.shared => 2,
|
||||
};
|
||||
const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{
|
||||
.global_template = null,
|
||||
.global_object = reuse_global_object,
|
||||
@@ -297,11 +302,19 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
|
||||
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
|
||||
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
|
||||
.subtype = .node,
|
||||
} else .{
|
||||
.value = @ptrCast(global._type.dedicated),
|
||||
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
|
||||
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
|
||||
.subtype = null,
|
||||
} else switch (global._type) {
|
||||
.dedicated => |scope| .{
|
||||
.value = @ptrCast(scope),
|
||||
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
|
||||
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
|
||||
.subtype = null,
|
||||
},
|
||||
.shared => |scope| .{
|
||||
.value = @ptrCast(scope),
|
||||
.prototype_chain = (&SharedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
|
||||
.prototype_len = @intCast(SharedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
|
||||
.subtype = null,
|
||||
},
|
||||
};
|
||||
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
|
||||
|
||||
@@ -349,7 +362,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
|
||||
|
||||
// Register in the identity map. Multiple contexts can be created for the
|
||||
// same global (via CDP), so we only register the first one.
|
||||
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else @intFromPtr(global._type.dedicated);
|
||||
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else switch (global._type) {
|
||||
inline else => |scope| @intFromPtr(scope),
|
||||
};
|
||||
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr);
|
||||
if (gop.found_existing == false) {
|
||||
var global_global: v8.Global = undefined;
|
||||
@@ -578,6 +593,7 @@ pub fn protectHeapLimit(self: *Env) void {
|
||||
// V8 expects.
|
||||
fn nearHeapLimit(data: ?*anyopaque, current_limit: usize, initial_limit: usize) callconv(.c) usize {
|
||||
const self: *Env = @ptrCast(@alignCast(data.?));
|
||||
lp.metrics.js_heap_limits.incr();
|
||||
log.err(.app, "JS heap limit reached", .{
|
||||
.initial_limit = initial_limit,
|
||||
.current_limit = current_limit,
|
||||
|
||||
@@ -33,7 +33,7 @@ const Scheduler = @import("Scheduler.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const Factory = @import("../Factory.zig");
|
||||
const HttpClient = @import("../HttpClient.zig");
|
||||
const HttpClient = @import("../../network/HttpClient.zig");
|
||||
const EventManagerBase = @import("../EventManagerBase.zig");
|
||||
|
||||
const Event = @import("../webapi/Event.zig");
|
||||
@@ -102,12 +102,27 @@ pub fn makeRequest(self: *const Execution, req: HttpClient.Request) !void {
|
||||
};
|
||||
}
|
||||
|
||||
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
|
||||
pub fn newRequest(self: *const Execution, req: HttpClient.Request) !*HttpClient.Transfer {
|
||||
return switch (self.js.global) {
|
||||
inline else => |g| g.newRequest(req),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getBroadcastChannels(self: *const Execution) *std.DoublyLinkedList {
|
||||
return switch (self.js.global) {
|
||||
inline else => |g| &g._broadcast_channels,
|
||||
};
|
||||
}
|
||||
|
||||
// The owning global's (Frame or WGS) list of live MessagePorts, walked at
|
||||
// that global's teardown to sever cross-context entanglement.
|
||||
pub fn messagePorts(self: *const Execution) *std.DoublyLinkedList {
|
||||
return switch (self.js.global) {
|
||||
inline else => |g| &g._message_ports,
|
||||
};
|
||||
}
|
||||
|
||||
// The global's serialized origin (e.g. "https://example.com"), or null for an
|
||||
// opaque origin.
|
||||
pub fn origin(self: *const Execution) ?[]const u8 {
|
||||
|
||||
@@ -95,7 +95,11 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
|
||||
pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
|
||||
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
|
||||
if (err != error.TryCatchRethrow) {
|
||||
// error.TryCatchRethrow is a control flow (sorry!), not an actual
|
||||
// error we want to log
|
||||
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
|
||||
}
|
||||
return err;
|
||||
};
|
||||
}
|
||||
@@ -108,6 +112,14 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
|
||||
};
|
||||
}
|
||||
|
||||
// Like callWithThis, but a thrown JS exception is rethrown past the internal
|
||||
// TryCatch, so an enclosing TryCatch of the caller can observe the exception
|
||||
// value itself (e.g. to report it to window.onerror).
|
||||
pub fn callWithThisRethrow(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
|
||||
var caught: js.TryCatch.Caught = undefined;
|
||||
return self._tryCallWithThis(T, this, args, &caught, .{ .rethrow = true });
|
||||
}
|
||||
|
||||
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
|
||||
return self._tryCallWithThis(T, self.getThis(), args, caught, .{});
|
||||
}
|
||||
@@ -227,29 +239,7 @@ pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {
|
||||
}
|
||||
|
||||
pub fn persist(self: *const Function) !Global {
|
||||
return self._persist(true);
|
||||
}
|
||||
|
||||
pub fn temp(self: *const Function) !Temp {
|
||||
return self._persist(false);
|
||||
}
|
||||
|
||||
fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) {
|
||||
var ctx = self.local.ctx;
|
||||
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .temps = &ctx.page.temps };
|
||||
}
|
||||
|
||||
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
|
||||
const with_this = try self.withThis(value);
|
||||
return with_this.temp();
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
pub fn persistWithThis(self: *const Function, value: anytype) !Global {
|
||||
@@ -257,41 +247,23 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
|
||||
return with_this.persist();
|
||||
}
|
||||
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
// A cheap, copyable handle to a persisted function. See js.GlobalSlot.
|
||||
pub const Global = struct {
|
||||
slot: *js.GlobalSlot,
|
||||
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
pub const release = deinit;
|
||||
|
||||
pub fn local(self: Global, l: *const js.Local) Function {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isEqual(self: Global, other: Function) bool {
|
||||
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
|
||||
}
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
}
|
||||
|
||||
pub fn local(self: *const Self, l: *const js.Local) Function {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,7 +275,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
|
||||
const gop = try ctx.addIdentity(resolved_ptr_id);
|
||||
if (gop.found_existing) {
|
||||
// we've seen this instance before, return the same object
|
||||
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
|
||||
return .{
|
||||
.local = self,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(gop.value_ptr, self.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
const isolate = self.isolate;
|
||||
@@ -468,12 +471,9 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
|
||||
|
||||
inline
|
||||
js.Function.Global,
|
||||
js.Function.Temp,
|
||||
js.Value.Global,
|
||||
js.Value.Temp,
|
||||
js.Object.Global,
|
||||
js.Promise.Global,
|
||||
js.Promise.Temp,
|
||||
js.PromiseResolver.Global,
|
||||
js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) },
|
||||
|
||||
@@ -742,15 +742,22 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
|
||||
// Extracted so that it can be used in both jsValueToZig and in
|
||||
// probeJsValueToZig. Avoids having to duplicate this logic when probing.
|
||||
fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
|
||||
// js.Nullable(T): a required argument that accepts null.
|
||||
if (@hasDecl(T, "js_nullable")) {
|
||||
if (js_val.isNullOrUndefined()) {
|
||||
return T{ .value = null };
|
||||
}
|
||||
return T{ .value = try self.jsValueToZig(T.js_nullable, js_val) };
|
||||
}
|
||||
|
||||
return switch (T) {
|
||||
js.Function, js.Function.Global, js.Function.Temp => {
|
||||
js.Function, js.Function.Global => {
|
||||
if (!js_val.isFunction()) {
|
||||
return null;
|
||||
}
|
||||
const js_func = js.Function{ .local = self, .handle = @ptrCast(js_val.handle) };
|
||||
return switch (T) {
|
||||
js.Function => js_func,
|
||||
js.Function.Temp => try js_func.temp(),
|
||||
js.Function.Global => try js_func.persist(),
|
||||
else => unreachable,
|
||||
};
|
||||
@@ -767,7 +774,6 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
|
||||
},
|
||||
js.Value => js_val,
|
||||
js.Value.Global => return try js_val.persist(),
|
||||
js.Value.Temp => return try js_val.temp(),
|
||||
js.Object => {
|
||||
if (!js_val.isObject()) {
|
||||
return null;
|
||||
@@ -788,7 +794,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
|
||||
return try obj.persist();
|
||||
},
|
||||
|
||||
js.Promise.Global, js.Promise.Temp => {
|
||||
js.Promise.Global => {
|
||||
if (!js_val.isPromise()) {
|
||||
return null;
|
||||
}
|
||||
@@ -796,11 +802,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
|
||||
.local = self,
|
||||
.handle = @ptrCast(js_val.handle),
|
||||
};
|
||||
return switch (T) {
|
||||
js.Promise.Temp => try js_promise.temp(),
|
||||
js.Promise.Global => try js_promise.persist(),
|
||||
else => unreachable,
|
||||
};
|
||||
return try js_promise.persist();
|
||||
},
|
||||
js.String => return js_val.isString(),
|
||||
js.String.OneByte => {
|
||||
|
||||
@@ -92,14 +92,7 @@ pub fn format(self: Object, writer: *std.Io.Writer) !void {
|
||||
}
|
||||
|
||||
pub fn persist(self: Object) !Global {
|
||||
var ctx = self.local.ctx;
|
||||
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
|
||||
try ctx.trackGlobal(global);
|
||||
|
||||
return .{ .handle = global };
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
pub fn getFunction(self: Object, name: []const u8) !?js.Function {
|
||||
@@ -171,21 +164,24 @@ pub fn toZig(self: Object, comptime T: type) !T {
|
||||
}
|
||||
|
||||
pub const Global = struct {
|
||||
handle: v8.Global,
|
||||
slot: *js.GlobalSlot,
|
||||
|
||||
pub fn deinit(self: *Global) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
|
||||
pub fn local(self: *const Global, l: *const js.Local) Object {
|
||||
// TODO: deprecated. @GlobalSlot
|
||||
pub const release = deinit;
|
||||
|
||||
pub fn local(self: Global, l: *const js.Local) Object {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isEqual(self: *const Global, other: Object) bool {
|
||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||
pub fn isEqual(self: Global, other: Object) bool {
|
||||
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -74,57 +74,21 @@ pub fn markAsHandled(self: Promise) void {
|
||||
}
|
||||
|
||||
pub fn persist(self: Promise) !Global {
|
||||
return self._persist(true);
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
pub fn temp(self: Promise) !Temp {
|
||||
return self._persist(false);
|
||||
}
|
||||
pub const Global = struct {
|
||||
slot: *js.GlobalSlot,
|
||||
|
||||
fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Global else Temp) {
|
||||
var ctx = self.local.ctx;
|
||||
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .temps = {} };
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .temps = &ctx.page.temps };
|
||||
}
|
||||
pub const release = deinit;
|
||||
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
pub fn local(self: Global, l: *const js.Local) Promise {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
}
|
||||
|
||||
pub fn local(self: *const Self, l: *const js.Local) Promise {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,24 +118,22 @@ fn _reject(self: PromiseResolver, value: anytype) !void {
|
||||
}
|
||||
|
||||
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.trackGlobal(global);
|
||||
return .{ .handle = global };
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
pub const Global = struct {
|
||||
handle: v8.Global,
|
||||
slot: *js.GlobalSlot,
|
||||
|
||||
pub fn deinit(self: *Global) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
|
||||
pub fn local(self: *const Global, l: *const js.Local) PromiseResolver {
|
||||
pub const release = deinit;
|
||||
|
||||
pub fn local(self: Global, l: *const js.Local) PromiseResolver {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,11 +22,18 @@ const lp = @import("lightpanda");
|
||||
const js = @import("js.zig");
|
||||
const bridge = @import("bridge.zig");
|
||||
|
||||
// Not ideal, but its children have special constructor rules (can be extended
|
||||
// can't be instantiated). And rather than coming up with a generic definition
|
||||
// for this one case, we just hardcode it.
|
||||
const HtmlElement = @import("../webapi/element/Html.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
const log = lp.log;
|
||||
const JsApis = bridge.JsApis;
|
||||
const PageJsApis = bridge.PageJsApis;
|
||||
const WorkerJsApis = bridge.WorkerJsApis;
|
||||
const SharedWorkerJsApis = bridge.SharedWorkerJsApis;
|
||||
const DedicatedWorkerJsApis = bridge.DedicatedWorkerJsApis;
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Snapshot = @This();
|
||||
@@ -204,9 +211,15 @@ pub fn create() !Snapshot {
|
||||
|
||||
{
|
||||
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
|
||||
const index = try createSnapshotContext(.worker, &WorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
|
||||
const index = try createSnapshotContext(.worker, &DedicatedWorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
|
||||
std.debug.assert(index == 1);
|
||||
}
|
||||
|
||||
{
|
||||
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
|
||||
const index = try createSnapshotContext(.worker, &SharedWorkerJsApis, SharedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
|
||||
std.debug.assert(index == 2);
|
||||
}
|
||||
}
|
||||
|
||||
const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep);
|
||||
@@ -402,6 +415,9 @@ fn countExternalReferences() comptime_int {
|
||||
// +1 for the illegal constructor callback shared by various types
|
||||
count += 1;
|
||||
|
||||
// +1 for the upgrade constructor shared by built-in element interfaces
|
||||
count += 1;
|
||||
|
||||
// +1 for the noop function shared by various types
|
||||
count += 1;
|
||||
|
||||
@@ -446,12 +462,15 @@ fn countExternalReferences() comptime_int {
|
||||
if (value.setter != null) count += 1;
|
||||
if (value.deleter != null) count += 1;
|
||||
if (value.query != null) count += 1;
|
||||
if (value.definer != null) count += 1;
|
||||
} else if (T == bridge.NamedIndexed) {
|
||||
count += 1;
|
||||
if (value.setter != null) count += 1;
|
||||
if (value.deleter != null) count += 1;
|
||||
if (value.enumerator != null) count += 1;
|
||||
if (value.query != null) count += 1;
|
||||
if (value.definer != null) count += 1;
|
||||
if (value.descriptor != null) count += 1;
|
||||
if (value.enumerator != null) count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,6 +493,9 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
||||
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
|
||||
idx += 1;
|
||||
|
||||
references[idx] = @bitCast(@intFromPtr(HtmlElement.JsApi.upgrade_constructor.func));
|
||||
idx += 1;
|
||||
|
||||
references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
|
||||
idx += 1;
|
||||
|
||||
@@ -536,6 +558,10 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
||||
references[idx] = @bitCast(@intFromPtr(query));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.definer) |definer| {
|
||||
references[idx] = @bitCast(@intFromPtr(definer));
|
||||
idx += 1;
|
||||
}
|
||||
} else if (T == bridge.NamedIndexed) {
|
||||
references[idx] = @bitCast(@intFromPtr(value.getter));
|
||||
idx += 1;
|
||||
@@ -547,14 +573,22 @@ fn collectExternalReferences() [countExternalReferences()]isize {
|
||||
references[idx] = @bitCast(@intFromPtr(deleter));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.enumerator) |enumerator| {
|
||||
references[idx] = @bitCast(@intFromPtr(enumerator));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.query) |query| {
|
||||
references[idx] = @bitCast(@intFromPtr(query));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.definer) |definer| {
|
||||
references[idx] = @bitCast(@intFromPtr(definer));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.descriptor) |descriptor| {
|
||||
references[idx] = @bitCast(@intFromPtr(descriptor));
|
||||
idx += 1;
|
||||
}
|
||||
if (value.enumerator) |enumerator| {
|
||||
references[idx] = @bitCast(@intFromPtr(enumerator));
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,14 +710,15 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 {
|
||||
|
||||
// Generate a constructor template for a JsApi type (public for reuse)
|
||||
pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
|
||||
const callback = blk: {
|
||||
const callback, const arity = comptime blk: {
|
||||
if (@hasDecl(JsApi, "constructor")) {
|
||||
break :blk JsApi.constructor.func;
|
||||
break :blk .{ JsApi.constructor.func, JsApi.constructor.arity };
|
||||
}
|
||||
break :blk illegalConstructorCallback;
|
||||
if (inheritsFromHtmlElement(JsApi)) {
|
||||
break :blk .{ HtmlElement.JsApi.upgrade_constructor.func, @as(c_int, HtmlElement.JsApi.upgrade_constructor.arity) };
|
||||
}
|
||||
break :blk .{ &illegalConstructorCallback, @as(c_int, 0) };
|
||||
};
|
||||
|
||||
const arity: c_int = if (@hasDecl(JsApi, "constructor")) JsApi.constructor.arity else 0;
|
||||
const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.length = arity,
|
||||
.callback = callback,
|
||||
@@ -704,12 +739,21 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8
|
||||
return template;
|
||||
}
|
||||
|
||||
// Attach JsApi members to a template (public for reuse). This is called on all
|
||||
// types. But, for globals (window, WGS) it's called twice. The first time, it's
|
||||
// called like any other interface. The 2nd time, it's called with flatten == true
|
||||
// and define_on != null. This is the "flattening" pass, and it defines all of
|
||||
// the functions/accessors on directly on the global instance. Thus, globals have
|
||||
// it defined on both their prototype (first pass) and their own instance (2nd pass).
|
||||
// hard-coded special case for HtmlElement which can be extended but not
|
||||
// instantiated.
|
||||
fn inheritsFromHtmlElement(comptime JsApi: type) bool {
|
||||
if (JsApi.bridge.type == HtmlElement) {
|
||||
return false;
|
||||
}
|
||||
return bridge.inheritsOrIs(JsApi, HtmlElement.JsApi);
|
||||
}
|
||||
|
||||
const Unforgeable = struct {
|
||||
Owner: type,
|
||||
name: [:0]const u8,
|
||||
accessor: bridge.Accessor,
|
||||
};
|
||||
|
||||
fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, define_on: ?*const v8.ObjectTemplate) void {
|
||||
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
|
||||
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
|
||||
@@ -745,49 +789,12 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
|
||||
continue;
|
||||
}
|
||||
|
||||
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
const getter_signature = if (value.static) null else signature;
|
||||
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = value.getter,
|
||||
.signature = getter_signature,
|
||||
}).?;
|
||||
// WebIDL: getter function's .name should be "get X"
|
||||
const getter_name_str = "get " ++ name;
|
||||
const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len));
|
||||
v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8);
|
||||
|
||||
const setter_callback = if (value.setter) |setter| blk: {
|
||||
const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = setter,
|
||||
.signature = getter_signature,
|
||||
.length = 1,
|
||||
}).?;
|
||||
const setter_name_str = "set " ++ name;
|
||||
const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len));
|
||||
v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8);
|
||||
break :blk cb;
|
||||
} else null;
|
||||
|
||||
var attribute: v8.PropertyAttribute = 0;
|
||||
if (value.setter == null) {
|
||||
attribute |= v8.ReadOnly;
|
||||
}
|
||||
if (value.deletable == false) {
|
||||
attribute |= v8.DontDelete;
|
||||
}
|
||||
|
||||
if (value.static) {
|
||||
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
|
||||
} else {
|
||||
// Web IDL: attributes on the interface prototype object
|
||||
// (and mirrored onto [Global] instances) are enumerable.
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__Config(define_on orelse prototype, &.{
|
||||
.key = js_name,
|
||||
.getter = getter_callback,
|
||||
.setter = setter_callback,
|
||||
.attribute = attribute,
|
||||
});
|
||||
if (comptime value.unforgeable) {
|
||||
// this accessor will be handled in the unforgeables loop
|
||||
// later in this function.
|
||||
continue;
|
||||
}
|
||||
attachAccessorProperty(name, value, isolate, template, signature, define_on orelse prototype);
|
||||
},
|
||||
bridge.Function => {
|
||||
if (value.wpt_only and wpt_extensions_enabled == false) {
|
||||
@@ -820,7 +827,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
|
||||
.setter = value.setter,
|
||||
.query = value.query,
|
||||
.deleter = value.deleter,
|
||||
.definer = null,
|
||||
.definer = if (value.definer) |definer| @ptrCast(definer) else null,
|
||||
.descriptor = null,
|
||||
.index_of = null,
|
||||
.data = null,
|
||||
@@ -835,8 +842,8 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
|
||||
.query = value.query,
|
||||
.deleter = value.deleter,
|
||||
.enumerator = value.enumerator,
|
||||
.definer = null,
|
||||
.descriptor = null,
|
||||
.definer = if (value.definer) |definer| @ptrCast(definer) else null,
|
||||
.descriptor = if (value.descriptor) |descriptor| @ptrCast(descriptor) else null,
|
||||
.data = null,
|
||||
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
|
||||
};
|
||||
@@ -873,6 +880,16 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime flatten == false) {
|
||||
inline for (unforgeables) |u| {
|
||||
if (comptime bridge.inheritsOrIs(JsApi, u.Owner)) {
|
||||
// unforgeables attributes aren't only applied directly on the
|
||||
// instance, they're also applied directly on every child instance
|
||||
attachAccessorProperty(u.name, u.accessor, isolate, template, signature, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The remaining per-class setup targets the class's own instance template;
|
||||
// in [Global] flattening mode the global already has these (or doesn't need
|
||||
// them), so skip it.
|
||||
@@ -923,3 +940,71 @@ fn globalScopeChain(comptime GlobalScopeApi: type) []const type {
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
|
||||
// Every [LegacyUnforgeable] accessor. We collect this once so we don't need to
|
||||
// keep scanning for this in attachClass
|
||||
const unforgeables: []const Unforgeable = blk: {
|
||||
@setEvalBranchQuota(100_000);
|
||||
var list: []const Unforgeable = &.{};
|
||||
for (JsApis) |Api| {
|
||||
for (@typeInfo(Api).@"struct".decls) |d| {
|
||||
const value = @field(Api, d.name);
|
||||
if (@TypeOf(value) == bridge.Accessor and value.unforgeable and !value.static) {
|
||||
list = list ++ &[_]Unforgeable{.{ .Owner = Api, .name = d.name, .accessor = value }};
|
||||
}
|
||||
}
|
||||
}
|
||||
break :blk list;
|
||||
};
|
||||
|
||||
// Attach JsApi members to a template (public for reuse). This is called on all
|
||||
// types. But, for globals (window, WGS) it's called twice. The first time, it's
|
||||
// called like any other interface. The 2nd time, it's called with flatten == true
|
||||
// and define_on != null. This is the "flattening" pass, and it defines all of
|
||||
// the functions/accessors on directly on the global instance. Thus, globals have
|
||||
// it defined on both their prototype (first pass) and their own instance (2nd pass).
|
||||
fn attachAccessorProperty(comptime name: [:0]const u8, value: bridge.Accessor, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, signature: anytype, target: anytype) void {
|
||||
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
const getter_signature = if (value.static) null else signature;
|
||||
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = value.getter,
|
||||
.signature = getter_signature,
|
||||
}).?;
|
||||
// WebIDL: getter function's .name should be "get X"
|
||||
const getter_name_str = "get " ++ name;
|
||||
const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len));
|
||||
v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8);
|
||||
|
||||
const setter_callback = if (value.setter) |setter| blk: {
|
||||
const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = setter,
|
||||
.signature = getter_signature,
|
||||
.length = 1,
|
||||
}).?;
|
||||
const setter_name_str = "set " ++ name;
|
||||
const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len));
|
||||
v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8);
|
||||
break :blk cb;
|
||||
} else null;
|
||||
|
||||
var attribute: v8.PropertyAttribute = 0;
|
||||
if (value.setter == null) {
|
||||
attribute |= v8.ReadOnly;
|
||||
}
|
||||
if (value.deletable == false) {
|
||||
attribute |= v8.DontDelete;
|
||||
}
|
||||
|
||||
if (value.static) {
|
||||
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
|
||||
} else {
|
||||
// Web IDL: attributes on the interface prototype object
|
||||
// (and mirrored onto [Global] instances) are enumerable.
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__Config(target, &.{
|
||||
.key = js_name,
|
||||
.getter = getter_callback,
|
||||
.setter = setter_callback,
|
||||
.attribute = attribute,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,14 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
|
||||
@compileError("unknown Zig type: " ++ @typeName(R));
|
||||
}
|
||||
|
||||
const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?;
|
||||
const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0) orelse return error.InvalidArgument;
|
||||
// A wrapped object always embeds an aligned TaggedOpaque pointer. An
|
||||
// unaligned value is a leftover v8 tagged value: the object was created
|
||||
// from our template but never mapped to a Zig instance (e.g. a custom
|
||||
// element whose constructor threw).
|
||||
if (@intFromPtr(tao_ptr) % @alignOf(TaggedOpaque) != 0) {
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr));
|
||||
const expected_type_index = bridge.JsApiLookup.getId(JsApi);
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ pub fn rethrow(self: *TryCatch) void {
|
||||
_ = v8.v8__TryCatch__ReThrow(&self.handle);
|
||||
}
|
||||
|
||||
// The raw caught exception value, e.g. to report it to a global's onerror.
|
||||
pub fn exceptionValue(self: TryCatch) ?js.Value {
|
||||
const handle = v8.v8__TryCatch__Exception(&self.handle) orelse return null;
|
||||
return .{ .local = self.local, .handle = handle };
|
||||
}
|
||||
|
||||
pub fn caught(self: TryCatch, allocator: Allocator) ?Caught {
|
||||
if (self.hasCaught() == false) {
|
||||
return null;
|
||||
|
||||
@@ -425,6 +425,8 @@ const cloneable_types = .{
|
||||
@import("../webapi/ImageData.zig"),
|
||||
@import("../webapi/DOMPointReadOnly.zig"),
|
||||
@import("../webapi/DOMPoint.zig"),
|
||||
@import("../webapi/DOMRectReadOnly.zig"),
|
||||
@import("../webapi/DOMRect.zig"),
|
||||
};
|
||||
|
||||
// Passed to a type's structuredSerialize hook to write its payload into the
|
||||
@@ -622,34 +624,17 @@ const CloneDelegate = struct {
|
||||
};
|
||||
|
||||
pub fn persist(self: Value) !Global {
|
||||
return self._persist(true);
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
pub fn temp(self: Value) !Temp {
|
||||
return self._persist(false);
|
||||
}
|
||||
|
||||
// Like persist(), but not tracked on the context: the caller owns the handle
|
||||
// and must deinit (Reset) it. A reset is only idempotent through the same
|
||||
// instance — copies alias one v8 slot, so keep a single canonical instance and
|
||||
// reset through it.
|
||||
pub fn bare(self: Value) BareGlobal {
|
||||
// like persist, but not tracked by the page. Caller takes responsibility for
|
||||
// resetting and freeing the allocation.
|
||||
pub fn persistBare(self: Value, arena: std.mem.Allocator) !*js.GlobalSlot {
|
||||
const slot = try arena.create(js.GlobalSlot);
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(self.local.ctx.isolate.handle, self.handle, &global);
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
|
||||
fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) {
|
||||
var ctx = self.local.ctx;
|
||||
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
if (comptime is_global) {
|
||||
try ctx.trackGlobal(global);
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .temps = &ctx.page.temps };
|
||||
slot.* = .{ .handle = global, .tracker = null, .gindex = undefined };
|
||||
return slot;
|
||||
}
|
||||
|
||||
pub fn toZig(self: Value, comptime T: type) !T {
|
||||
@@ -696,48 +681,71 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
|
||||
return js_str.format(writer);
|
||||
}
|
||||
|
||||
pub const Temp = G(.temp);
|
||||
pub const Global = G(.global);
|
||||
pub const BareGlobal = G(.bare);
|
||||
// Copyable handle to our v8::Global wrapper so that releasing a copy resets
|
||||
// the underlying v8::Global
|
||||
pub const Global = struct {
|
||||
slot: *js.GlobalSlot,
|
||||
|
||||
const GlobalType = enum(u8) {
|
||||
temp,
|
||||
global,
|
||||
bare,
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
pub const release = deinit;
|
||||
|
||||
pub fn local(self: Global, l: *const js.Local) Value {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isEqual(self: Global, other: Value) bool {
|
||||
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
|
||||
}
|
||||
};
|
||||
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Value: persisted handle early-release swap-removes and fixes up indices" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
const Self = @This();
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
}
|
||||
const tracker = &frame.js.page.globals;
|
||||
const base = tracker.list.items.len;
|
||||
|
||||
pub fn local(self: *const Self, l: *const js.Local) Value {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
var a = try (try ls.local.exec("({a:1})", null)).persist();
|
||||
var b = try (try ls.local.exec("({b:2})", null)).persist();
|
||||
var c = try (try ls.local.exec("({c:3})", null)).persist();
|
||||
|
||||
pub fn isEqual(self: *const Self, other: Value) bool {
|
||||
return v8.v8__Global__IsEqual(&self.handle, other.handle);
|
||||
}
|
||||
try testing.expectEqual(base + 3, tracker.list.items.len);
|
||||
try testing.expectEqual(base + 0, a.slot.gindex);
|
||||
try testing.expectEqual(base + 1, b.slot.gindex);
|
||||
try testing.expectEqual(base + 2, c.slot.gindex);
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Release the middle one: the last live slot (c) must move into b's spot and
|
||||
// have its stored index rewritten to match, or a later release corrupts.
|
||||
b.deinit();
|
||||
try testing.expectEqual(base + 2, tracker.list.items.len);
|
||||
try testing.expectEqual(base + 1, c.slot.gindex);
|
||||
try testing.expectEqual(c.slot, tracker.list.items[base + 1]);
|
||||
try testing.expectEqual(a.slot, tracker.list.items[base + 0]);
|
||||
|
||||
// a and c are still usable (right handle, not b's).
|
||||
try testing.expect(a.local(&ls.local).isObject());
|
||||
try testing.expect(c.local(&ls.local).isObject());
|
||||
|
||||
// Release the remaining two via the moved indices — must not corrupt.
|
||||
a.deinit();
|
||||
try testing.expectEqual(base + 1, tracker.list.items.len);
|
||||
try testing.expectEqual(base + 0, c.slot.gindex);
|
||||
try testing.expectEqual(c.slot, tracker.list.items[base + 0]);
|
||||
|
||||
c.deinit();
|
||||
try testing.expectEqual(base, tracker.list.items.len);
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Value: jsonStringify maps unserializable JS values to null" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
@@ -46,15 +46,23 @@ pub fn Builder(comptime T: type) type {
|
||||
}
|
||||
|
||||
pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
|
||||
return Indexed.init(T, getter_func, null, null, null, enumerator_func, opts);
|
||||
return Indexed.init(T, getter_func, null, null, null, null, enumerator_func, opts);
|
||||
}
|
||||
|
||||
pub fn indexedReadWrite(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
|
||||
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, enumerator_func, opts);
|
||||
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, null, enumerator_func, opts);
|
||||
}
|
||||
|
||||
pub fn indexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, definer_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
|
||||
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, definer_func, enumerator_func, opts);
|
||||
}
|
||||
|
||||
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
|
||||
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, opts);
|
||||
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, null, null, opts);
|
||||
}
|
||||
|
||||
pub fn namedIndexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, definer_func: anytype, descriptor_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
|
||||
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, definer_func, descriptor_func, opts);
|
||||
}
|
||||
|
||||
pub fn iterator(comptime func: anytype, comptime opts: Iterator.Opts) Iterator {
|
||||
@@ -222,6 +230,7 @@ pub const Accessor = struct {
|
||||
static: bool = false,
|
||||
deletable: bool = true,
|
||||
wpt_only: bool = false,
|
||||
unforgeable: bool = false, // Web IDL [LegacyUnforgeable]
|
||||
exposed: Caller.Function.Opts.Exposed = .both,
|
||||
cache: ?Caller.Function.Opts.Caching = null,
|
||||
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
|
||||
@@ -233,6 +242,7 @@ pub const Accessor = struct {
|
||||
.static = opts.static,
|
||||
.wpt_only = opts.wpt_only,
|
||||
.deletable = opts.deletable,
|
||||
.unforgeable = opts.unforgeable,
|
||||
.exposed = opts.exposed,
|
||||
};
|
||||
|
||||
@@ -268,13 +278,16 @@ pub const Indexed = struct {
|
||||
setter: ?*const fn (idx: u32, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
deleter: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
query: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
// v8 expects an Intercepted (u32) return; the stale void-returning
|
||||
// typedef in binding.h is papered over with a @ptrCast at install time.
|
||||
definer: ?*const fn (idx: u32, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
|
||||
const Opts = struct {
|
||||
as_typed_array: bool = false,
|
||||
null_as_undefined: bool = false,
|
||||
};
|
||||
|
||||
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
|
||||
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, definer: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
|
||||
var indexed = Indexed{
|
||||
.enumerator = null,
|
||||
.getter = struct {
|
||||
@@ -336,7 +349,7 @@ pub const Indexed = struct {
|
||||
}
|
||||
defer caller.deinit();
|
||||
|
||||
return caller.deleteIndex(T, deleter, idx, handle.?, .{
|
||||
return caller.deleteOrDefineIndex(T, deleter, idx, handle.?, .{
|
||||
.as_typed_array = opts.as_typed_array,
|
||||
.null_as_undefined = opts.null_as_undefined,
|
||||
});
|
||||
@@ -359,6 +372,22 @@ pub const Indexed = struct {
|
||||
}.wrap;
|
||||
}
|
||||
|
||||
if (@typeInfo(@TypeOf(definer)) != .null) {
|
||||
indexed.definer = struct {
|
||||
fn wrap(idx: u32, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
var caller: Caller = undefined;
|
||||
if (!caller.init(v8_isolate)) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
defer caller.deinit();
|
||||
|
||||
// same (self, index) -> bool shape as a deleter
|
||||
return caller.deleteOrDefineIndex(T, definer, idx, handle.?, .{});
|
||||
}
|
||||
}.wrap;
|
||||
}
|
||||
|
||||
return indexed;
|
||||
}
|
||||
};
|
||||
@@ -367,8 +396,12 @@ pub const NamedIndexed = struct {
|
||||
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
|
||||
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
query: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
// v8 expects an Intercepted (u32) return; the stale void-returning
|
||||
// typedefs in binding.h are papered over with a @ptrCast at install time.
|
||||
definer: ?*const fn (c_name: ?*const v8.Name, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
descriptor: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
|
||||
|
||||
const Opts = struct {
|
||||
as_typed_array: bool = false,
|
||||
@@ -379,7 +412,7 @@ pub const NamedIndexed = struct {
|
||||
ce_reactions: bool = false,
|
||||
};
|
||||
|
||||
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, comptime opts: Opts) NamedIndexed {
|
||||
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, definer: anytype, descriptor: anytype, comptime opts: Opts) NamedIndexed {
|
||||
const getter_fn = struct {
|
||||
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
@@ -445,13 +478,42 @@ pub const NamedIndexed = struct {
|
||||
if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
|
||||
};
|
||||
|
||||
return caller.deleteNamedIndex(T, deleter, c_name.?, handle.?, .{
|
||||
return caller.deleteOrDefineNamedIndex(T, deleter, c_name.?, handle.?, .{
|
||||
.as_typed_array = opts.as_typed_array,
|
||||
.null_as_undefined = opts.null_as_undefined,
|
||||
});
|
||||
}
|
||||
}.wrap;
|
||||
|
||||
const definer_fn = if (@typeInfo(@TypeOf(definer)) == .null) null else struct {
|
||||
fn wrap(c_name: ?*const v8.Name, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
var caller: Caller = undefined;
|
||||
if (!caller.init(v8_isolate)) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
defer caller.deinit();
|
||||
|
||||
// same (self, name) -> bool shape as a deleter
|
||||
return caller.deleteOrDefineNamedIndex(T, definer, c_name.?, handle.?, .{});
|
||||
}
|
||||
}.wrap;
|
||||
|
||||
const descriptor_fn = if (@typeInfo(@TypeOf(descriptor)) == .null) null else struct {
|
||||
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
var caller: Caller = undefined;
|
||||
if (!caller.init(v8_isolate)) {
|
||||
return js.Intercepted.no;
|
||||
}
|
||||
defer caller.deinit();
|
||||
|
||||
// same (self, name) -> value shape as a getter; the returned
|
||||
// struct is converted to a JS property descriptor object
|
||||
return caller.getNamedIndex(T, descriptor, c_name.?, handle.?, .{});
|
||||
}
|
||||
}.wrap;
|
||||
|
||||
const enumerator_fn = if (@typeInfo(@TypeOf(enumerator)) == .null) null else struct {
|
||||
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
|
||||
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
|
||||
@@ -482,8 +544,10 @@ pub const NamedIndexed = struct {
|
||||
.getter = getter_fn,
|
||||
.setter = setter_fn,
|
||||
.deleter = deleter_fn,
|
||||
.enumerator = enumerator_fn,
|
||||
.query = query_fn,
|
||||
.definer = definer_fn,
|
||||
.descriptor = descriptor_fn,
|
||||
.enumerator = enumerator_fn,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -907,6 +971,7 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/DOMImplementation.zig"),
|
||||
@import("../webapi/DOMTreeWalker.zig"),
|
||||
@import("../webapi/DOMNodeIterator.zig"),
|
||||
@import("../webapi/DOMRectReadOnly.zig"),
|
||||
@import("../webapi/DOMRect.zig"),
|
||||
@import("../webapi/DOMMatrixReadOnly.zig"),
|
||||
@import("../webapi/DOMMatrix.zig"),
|
||||
@@ -995,6 +1060,23 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/element/html/ValidityState.zig"),
|
||||
@import("../webapi/element/Svg.zig"),
|
||||
@import("../webapi/element/svg/Generic.zig"),
|
||||
@import("../webapi/element/svg/Graphics.zig"),
|
||||
@import("../webapi/element/svg/Geometry.zig"),
|
||||
@import("../webapi/element/svg/Svg.zig"),
|
||||
@import("../webapi/element/svg/G.zig"),
|
||||
@import("../webapi/element/svg/A.zig"),
|
||||
@import("../webapi/element/svg/Use.zig"),
|
||||
@import("../webapi/element/svg/Image.zig"),
|
||||
@import("../webapi/element/svg/Defs.zig"),
|
||||
@import("../webapi/element/svg/Rect.zig"),
|
||||
@import("../webapi/element/svg/Circle.zig"),
|
||||
@import("../webapi/element/svg/Ellipse.zig"),
|
||||
@import("../webapi/element/svg/Line.zig"),
|
||||
@import("../webapi/element/svg/Path.zig"),
|
||||
@import("../webapi/element/svg/Polygon.zig"),
|
||||
@import("../webapi/element/svg/Polyline.zig"),
|
||||
@import("../webapi/svg/AnimatedString.zig"),
|
||||
@import("../webapi/svg/Number.zig"),
|
||||
@import("../webapi/encoding/TextDecoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoder.zig"),
|
||||
@import("../webapi/encoding/TextEncoderStream.zig"),
|
||||
@@ -1009,6 +1091,12 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/event/PageTransitionEvent.zig"),
|
||||
@import("../webapi/event/PopStateEvent.zig"),
|
||||
@import("../webapi/event/HashChangeEvent.zig"),
|
||||
@import("../webapi/event/BeforeUnloadEvent.zig"),
|
||||
@import("../webapi/event/StorageEvent.zig"),
|
||||
@import("../webapi/event/DeviceMotionEvent.zig"),
|
||||
@import("../webapi/event/GamepadEvent.zig"),
|
||||
@import("../webapi/event/DeviceOrientationEvent.zig"),
|
||||
@import("../webapi/event/TouchEvent.zig"),
|
||||
@import("../webapi/event/UIEvent.zig"),
|
||||
@import("../webapi/event/MouseEvent.zig"),
|
||||
@import("../webapi/event/PointerEvent.zig"),
|
||||
@@ -1026,6 +1114,7 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/MessagePort.zig"),
|
||||
@import("../webapi/BroadcastChannel.zig"),
|
||||
@import("../webapi/Worker.zig"),
|
||||
@import("../webapi/SharedWorker.zig"),
|
||||
@import("../webapi/media/MediaError.zig"),
|
||||
@import("../webapi/media/TextTrackCue.zig"),
|
||||
@import("../webapi/media/VTTCue.zig"),
|
||||
@@ -1045,6 +1134,7 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
|
||||
@import("../webapi/net/XMLHttpRequestUpload.zig"),
|
||||
@import("../webapi/net/WebSocket.zig"),
|
||||
@import("../webapi/net/EventSource.zig"),
|
||||
@import("../webapi/event/CloseEvent.zig"),
|
||||
@import("../webapi/streams/ReadableStream.zig"),
|
||||
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
|
||||
@@ -1093,11 +1183,13 @@ pub const PageJsApis = flattenTypes(&.{
|
||||
@import("../webapi/collections/DOMStringList.zig"),
|
||||
});
|
||||
|
||||
// APIs available on Worker context globals (constructors like URL, Headers, etc.)
|
||||
// APIs available on every Worker context global (constructors like URL,
|
||||
// Headers, etc.), regardless of worker kind. Each kind's snapshot context
|
||||
// adds its own global-scope type on top (DedicatedWorkerJsApis,
|
||||
// SharedWorkerJsApis below).
|
||||
// This is a subset of PageJsApis plus WorkerGlobalScope.
|
||||
// TODO: Expand this list to include all worker-appropriate APIs.
|
||||
pub const WorkerJsApis = flattenTypes(&.{
|
||||
@import("../webapi/DedicatedWorkerGlobalScope.zig"),
|
||||
const worker_common_apis = [_]type{
|
||||
@import("../webapi/WorkerGlobalScope.zig"),
|
||||
@import("../webapi/WorkerLocation.zig"),
|
||||
@import("../webapi/Navigator.zig"),
|
||||
@@ -1111,6 +1203,8 @@ pub const WorkerJsApis = flattenTypes(&.{
|
||||
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
||||
@import("../webapi/event/CloseEvent.zig"),
|
||||
@import("../webapi/DOMException.zig"),
|
||||
@import("../webapi/DOMRectReadOnly.zig"),
|
||||
@import("../webapi/DOMRect.zig"),
|
||||
@import("../webapi/DOMMatrixReadOnly.zig"),
|
||||
@import("../webapi/DOMMatrix.zig"),
|
||||
@import("../webapi/DOMPointReadOnly.zig"),
|
||||
@@ -1146,6 +1240,7 @@ pub const WorkerJsApis = flattenTypes(&.{
|
||||
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
|
||||
@import("../webapi/net/XMLHttpRequestUpload.zig"),
|
||||
@import("../webapi/net/WebSocket.zig"),
|
||||
@import("../webapi/net/EventSource.zig"),
|
||||
@import("../webapi/FileReader.zig"),
|
||||
@import("../webapi/ImageData.zig"),
|
||||
@import("../webapi/Performance.zig"),
|
||||
@@ -1161,7 +1256,10 @@ pub const WorkerJsApis = flattenTypes(&.{
|
||||
@import("../webapi/MessageChannel.zig"),
|
||||
@import("../webapi/MessagePort.zig"),
|
||||
@import("../webapi/collections/DOMStringList.zig"),
|
||||
});
|
||||
};
|
||||
|
||||
pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_common_apis));
|
||||
pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_common_apis));
|
||||
|
||||
// Master list of ALL JS APIs across all contexts.
|
||||
// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs
|
||||
@@ -1170,6 +1268,7 @@ pub const WorkerJsApis = flattenTypes(&.{
|
||||
pub const JsApis = blk: {
|
||||
const base = PageJsApis ++ [_]type{
|
||||
@import("../webapi/DedicatedWorkerGlobalScope.zig").JsApi,
|
||||
@import("../webapi/SharedWorkerGlobalScope.zig").JsApi,
|
||||
@import("../webapi/WorkerGlobalScope.zig").JsApi,
|
||||
@import("../webapi/WorkerLocation.zig").JsApi,
|
||||
};
|
||||
@@ -1178,3 +1277,20 @@ pub const JsApis = blk: {
|
||||
}
|
||||
break :blk base ++ [_]type{@import("../webapi/WebDriver.zig").JsApi};
|
||||
};
|
||||
|
||||
// Whether Child is Ancestor or inherits from it, following the _proto chain.
|
||||
pub fn inheritsOrIs(comptime Child: type, comptime Ancestor: type) bool {
|
||||
comptime {
|
||||
const target = Ancestor.bridge.type;
|
||||
var T = Child.bridge.type;
|
||||
while (true) {
|
||||
if (T == target) {
|
||||
return true;
|
||||
}
|
||||
if (!@hasField(T, "_proto")) {
|
||||
return false;
|
||||
}
|
||||
T = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,84 @@ pub fn Bridge(comptime T: type) type {
|
||||
return bridge.Builder(T);
|
||||
}
|
||||
|
||||
// Our wrapper around a v8::Global designed to be tracked (in a GlobalTracker).
|
||||
pub const GlobalSlot = struct {
|
||||
handle: v8.Global,
|
||||
tracker: ?*GlobalTracker, // null for Bare globals (see IndexedDB)
|
||||
gindex: u32, // position in GlobalTracker, used to efficiently remove + reuse
|
||||
|
||||
pub fn reset(self: *GlobalSlot) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
}
|
||||
|
||||
// Eager free: reset the handle and, if page-tracked, drop the slot from the
|
||||
// tracker and return it to the pool. Idempotent for bare slots.
|
||||
pub fn release(self: *GlobalSlot) void {
|
||||
self.reset();
|
||||
if (self.tracker) |t| {
|
||||
t.untrack(self);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local(self: *const GlobalSlot, l: *const Local) Value {
|
||||
return .{
|
||||
.local = l,
|
||||
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Per-page owner of persisted v8 handles (v8::Global). Teardown resets all globals
|
||||
pub const GlobalTracker = struct {
|
||||
allocator: Allocator,
|
||||
list: std.ArrayList(*GlobalSlot) = .empty,
|
||||
pool: std.heap.MemoryPool(GlobalSlot),
|
||||
|
||||
pub fn init(allocator: Allocator) GlobalTracker {
|
||||
return .{ .allocator = allocator, .pool = std.heap.MemoryPool(GlobalSlot).init(allocator) };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *GlobalTracker) void {
|
||||
for (self.list.items) |slot| {
|
||||
slot.reset();
|
||||
}
|
||||
self.list.deinit(self.allocator);
|
||||
self.pool.deinit();
|
||||
}
|
||||
|
||||
pub fn track(self: *GlobalTracker, handle: v8.Global) !*GlobalSlot {
|
||||
const slot = try self.pool.create();
|
||||
errdefer self.pool.destroy(slot);
|
||||
slot.* = .{
|
||||
.handle = handle,
|
||||
.tracker = self,
|
||||
.gindex = @intCast(self.list.items.len),
|
||||
};
|
||||
try self.list.append(self.allocator, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
// swapRemove + updating the moved's index
|
||||
fn untrack(self: *GlobalTracker, slot: *GlobalSlot) void {
|
||||
const idx = slot.gindex;
|
||||
const moved = self.list.pop().?;
|
||||
if (moved != slot) {
|
||||
self.list.items[idx] = moved;
|
||||
// moved has..well...moved, we need to update its gindex
|
||||
moved.gindex = idx;
|
||||
}
|
||||
self.pool.destroy(slot);
|
||||
}
|
||||
};
|
||||
|
||||
// Build a v8.Global from a live handle and track it on the context's page.
|
||||
pub fn newTrackedSlot(ctx: *Context, handle: anytype) !*GlobalSlot {
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, handle, &global);
|
||||
errdefer v8.v8__Global__Reset(&global);
|
||||
return ctx.page.globals.track(global);
|
||||
}
|
||||
|
||||
// If a function returns a []i32, should that map to a plain-old
|
||||
// JavaScript array, or a Int32Array? It's ambiguous. By default, we'll
|
||||
// map arrays/slices to the JavaScript arrays. If you want a TypedArray
|
||||
@@ -126,14 +204,16 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
|
||||
|
||||
/// Persisted typed array.
|
||||
pub const Global = struct {
|
||||
handle: v8.Global,
|
||||
slot: *GlobalSlot,
|
||||
|
||||
pub fn deinit(self: *Global) void {
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
pub fn deinit(self: Global) void {
|
||||
self.slot.release();
|
||||
}
|
||||
|
||||
pub fn local(self: *const Global, l: *const Local) Self {
|
||||
return .{ .local = l, .handle = v8.v8__Global__Get(&self.handle, l.isolate.handle).? };
|
||||
pub const release = deinit;
|
||||
|
||||
pub fn local(self: Global, l: *const Local) Self {
|
||||
return .{ .local = l, .handle = v8.v8__Global__Get(&self.slot.handle, l.isolate.handle).? };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -173,12 +253,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
|
||||
}
|
||||
|
||||
pub fn persist(self: *const Self) !Global {
|
||||
var ctx = self.local.ctx;
|
||||
var global: v8.Global = undefined;
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
try ctx.trackGlobal(global);
|
||||
|
||||
return .{ .handle = global };
|
||||
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
|
||||
}
|
||||
|
||||
// Direct view into the typed array's backing memory.
|
||||
@@ -209,6 +284,18 @@ pub const NullableString = struct {
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
// A required argument that accepts null (Web IDL "T?"): unlike a Zig optional
|
||||
// parameter, omitting the argument is a TypeError, while passing null or
|
||||
// undefined yields .{ .value = null }. It also counts towards the JS-visible
|
||||
// function length, which a plain optional would not.
|
||||
pub fn Nullable(comptime T: type) type {
|
||||
return struct {
|
||||
value: ?T,
|
||||
|
||||
pub const js_nullable = T;
|
||||
};
|
||||
}
|
||||
|
||||
pub const Exception = struct {
|
||||
local: *const Local,
|
||||
handle: *const v8.Value,
|
||||
|
||||
@@ -142,7 +142,7 @@ fn isStandaloneAnchor(el: *Element) bool {
|
||||
|
||||
fn isSignificantText(node: *Node) bool {
|
||||
const text = node.is(Node.CData.Text) orelse return false;
|
||||
return !isAllWhitespace(text.getWholeText());
|
||||
return !isAllWhitespace(text.ownData());
|
||||
}
|
||||
|
||||
fn isVisibleElement(el: *Element) bool {
|
||||
|
||||
@@ -171,6 +171,16 @@ const Error = struct {
|
||||
};
|
||||
};
|
||||
|
||||
pub const PrescanResource = h5e.PrescanResource;
|
||||
pub const PrescanCallback = h5e.PrescanCallback;
|
||||
|
||||
// Preload scanner: a tokenizer-only pass over a buffered document, reporting
|
||||
// fetchable script resources (and the first <base href>) through `callback`.
|
||||
// Builds no tree; purely a hint source.
|
||||
pub fn prescan(html: []const u8, charset: []const u8, ctx: *anyopaque, callback: PrescanCallback) void {
|
||||
h5e.html5ever_prescan(html.ptr, html.len, charset.ptr, charset.len, ctx, callback);
|
||||
}
|
||||
|
||||
pub fn parse(self: *Parser, html: []const u8) void {
|
||||
h5e.html5ever_parse_document(
|
||||
html.ptr,
|
||||
@@ -272,6 +282,7 @@ pub fn parseFragment(self: *Parser, html: []const u8) void {
|
||||
&self.container,
|
||||
self,
|
||||
createElementCallback,
|
||||
createContextElementCallback,
|
||||
getDataCallback,
|
||||
appendCallback,
|
||||
parseErrorCallback,
|
||||
@@ -439,6 +450,25 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa
|
||||
return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml);
|
||||
}
|
||||
|
||||
// html5ever_parse_fragment materializes the fragment's context element through
|
||||
// this dedicated callback, never through createElementCallback. The context
|
||||
// element is a throwaway: html5ever only queries its name (and, for a
|
||||
// <template> context, its content), it is never inserted into the tree. It
|
||||
// must be built bare — running a custom element constructor here is unbounded
|
||||
// recursion when the constructor sets innerHTML: that re-parse needs a context
|
||||
// element of the same custom tag, reconstructing forever (stack overflow). No
|
||||
// _ce_reactions window is needed: nothing can enqueue reactions on a bare
|
||||
// create.
|
||||
fn createContextElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) callconv(.c) ?*anyopaque {
|
||||
const self: *Parser = @ptrCast(@alignCast(ctx));
|
||||
self.frame._skip_custom_element_upgrade = true;
|
||||
defer self.frame._skip_custom_element_upgrade = false;
|
||||
return self._createElementCallback(data, qname, attributes, .unknown) catch |err| {
|
||||
self.err = .{ .err = err, .source = .create_element };
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
fn _createElementCallbackWithDefaultnamespace(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) ?*anyopaque {
|
||||
const self: *Parser = @ptrCast(@alignCast(ctx));
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
|
||||
@@ -75,6 +75,7 @@ pub extern "c" fn html5ever_parse_fragment(
|
||||
doc: *anyopaque,
|
||||
ctx: *anyopaque,
|
||||
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
|
||||
createContextElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
|
||||
elemNameCallback: *const fn (node_ref: *anyopaque) callconv(.c) *anyopaque,
|
||||
appendCallback: *const fn (ctx: *anyopaque, parent_ref: *anyopaque, NodeOrText) callconv(.c) void,
|
||||
parseErrorCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) void,
|
||||
@@ -314,3 +315,23 @@ pub extern "c" fn encoding_max_encode_buffer_length(
|
||||
handle: *anyopaque,
|
||||
input_len: usize,
|
||||
) usize;
|
||||
|
||||
// Preload scanner (see prescan.rs). Kinds must stay in sync with the
|
||||
// KIND_* constants there.
|
||||
pub const PrescanResource = enum(u32) {
|
||||
script = 0,
|
||||
module = 1,
|
||||
base = 2,
|
||||
_,
|
||||
};
|
||||
|
||||
pub const PrescanCallback = *const fn (ctx: *anyopaque, kind: PrescanResource, url: [*c]const u8, url_len: usize) callconv(.c) void;
|
||||
|
||||
pub extern "c" fn html5ever_prescan(
|
||||
html: [*c]const u8,
|
||||
len: usize,
|
||||
charset: [*c]const u8,
|
||||
charset_len: usize,
|
||||
ctx: *anyopaque,
|
||||
callback: PrescanCallback,
|
||||
) void;
|
||||
|
||||
203
src/browser/tests/css/layer_at_rule_cascade.html
Normal file
203
src/browser/tests/css/layer_at_rule_cascade.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<script src="../testing.js"></script>
|
||||
</head>
|
||||
|
||||
<!--
|
||||
`@layer` block rules (CSS Cascade 5) are flattened into the cascade: the
|
||||
inner rules participate exactly as if they were written at the top level,
|
||||
recursing into nested `@media` / `@layer`. Layer-priority ordering
|
||||
(css-cascade-5 §6.4) is deliberately not implemented — ties keep breaking
|
||||
on specificity + document order. The statement form (`@layer a, b;`)
|
||||
declares ordering only and carries no block, so it is inert.
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* Named layer block — inner rules join the cascade. */
|
||||
@layer utilities {
|
||||
#hidden-by-layer { display: none; }
|
||||
}
|
||||
/* Anonymous layer block. */
|
||||
@layer {
|
||||
#hidden-by-anon-layer { display: none; }
|
||||
}
|
||||
/* Dotted sub-layer name in the prelude. */
|
||||
@layer theme.dark {
|
||||
#hidden-by-dotted-layer { display: none; }
|
||||
}
|
||||
/* Statement form: declares layer order, carries no block. It must be
|
||||
inert AND must not abort parsing of the rest of the sheet. */
|
||||
@layer theme, base, components, utilities;
|
||||
#hidden-after-statement { display: none; }
|
||||
/* Matching @media nested inside @layer — applies. */
|
||||
@layer responsive {
|
||||
@media (min-width: 600px) {
|
||||
#hidden-media-in-layer { display: none; }
|
||||
}
|
||||
}
|
||||
/* Non-matching @media nested inside @layer — dropped. */
|
||||
@layer responsive {
|
||||
@media print {
|
||||
#not-hidden-print-in-layer { display: none; }
|
||||
}
|
||||
}
|
||||
/* @layer nested inside @media. */
|
||||
@media screen {
|
||||
@layer inner {
|
||||
#hidden-layer-in-media { display: none; }
|
||||
}
|
||||
}
|
||||
/* @layer nested inside @layer. */
|
||||
@layer outer {
|
||||
@layer inner {
|
||||
#hidden-nested-layer { display: none; }
|
||||
}
|
||||
}
|
||||
/* Utility-style !important declaration inside a layer. */
|
||||
@layer utilities {
|
||||
#hidden-important { display: none !important; }
|
||||
}
|
||||
/* visibility:hidden inside a layer. */
|
||||
@layer utilities {
|
||||
#layer-visibility-hidden { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div id="hidden-by-layer">named</div>
|
||||
<div id="hidden-by-anon-layer">anonymous</div>
|
||||
<div id="hidden-by-dotted-layer">dotted</div>
|
||||
<div id="hidden-after-statement">after-statement</div>
|
||||
<div id="hidden-media-in-layer">media-in-layer</div>
|
||||
<div id="not-hidden-print-in-layer">print-in-layer</div>
|
||||
<div id="hidden-layer-in-media">layer-in-media</div>
|
||||
<div id="hidden-nested-layer">nested-layer</div>
|
||||
<div id="hidden-important">important</div>
|
||||
<div id="layer-visibility-hidden">visibility</div>
|
||||
<div id="dyn-target">dyn-target</div>
|
||||
<div id="dyn2-target">dyn2-target</div>
|
||||
<div id="deep-nest-target">deep-nest-target</div>
|
||||
<style id="dyn"></style>
|
||||
<style id="dyn2"></style>
|
||||
<style id="deep-nest-style"></style>
|
||||
</body>
|
||||
|
||||
<script id=layer_named_block_hides>
|
||||
{
|
||||
testing.expectFalse($('#hidden-by-layer').checkVisibility());
|
||||
// getComputedStyle consumes the same cascade.
|
||||
testing.expectEqual('none', getComputedStyle($('#hidden-by-layer')).display);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_anonymous_block_hides>
|
||||
{
|
||||
testing.expectFalse($('#hidden-by-anon-layer').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_dotted_name_hides>
|
||||
{
|
||||
testing.expectFalse($('#hidden-by-dotted-layer').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_statement_is_inert>
|
||||
{
|
||||
// The statement form has nothing to apply, and rules after it still parse.
|
||||
testing.expectFalse($('#hidden-after-statement').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_media_in_layer>
|
||||
{
|
||||
// Matching query inside a layer applies; non-matching is dropped.
|
||||
testing.expectFalse($('#hidden-media-in-layer').checkVisibility());
|
||||
testing.expectTrue($('#not-hidden-print-in-layer').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_in_media>
|
||||
{
|
||||
testing.expectFalse($('#hidden-layer-in-media').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_nested_layers>
|
||||
{
|
||||
testing.expectFalse($('#hidden-nested-layer').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_important_declaration>
|
||||
{
|
||||
testing.expectFalse($('#hidden-important').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_visibility_hidden>
|
||||
{
|
||||
// checkVisibility() defaults to display-only; visibilityProperty opts in.
|
||||
testing.expectTrue($('#layer-visibility-hidden').checkVisibility());
|
||||
testing.expectFalse(
|
||||
$('#layer-visibility-hidden').checkVisibility({ visibilityProperty: true })
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_dynamic_insertRule>
|
||||
{
|
||||
// The cssRules path (insertRule) must recurse into @layer too.
|
||||
const sheet = $('#dyn').sheet;
|
||||
const index = sheet.insertRule(
|
||||
'@layer utilities { #dyn-target { display: none; } }', 0
|
||||
);
|
||||
testing.expectEqual(0, index);
|
||||
testing.expectFalse($('#dyn-target').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_cssRules_surface>
|
||||
{
|
||||
// @layer rules postdate the legacy numeric CSSRule type constants, so
|
||||
// `type` is 0 (CSSOM §6.4.1) — same as Chrome's CSSLayerBlockRule.
|
||||
const sheet = $('#dyn').sheet;
|
||||
const rule = sheet.cssRules[0];
|
||||
testing.expectEqual(0, rule.type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_statement_insertRule>
|
||||
{
|
||||
// Statement form through insertRule: surfaced via cssRules, applies nothing.
|
||||
const sheet = $('#dyn').sheet;
|
||||
const index = sheet.insertRule('@layer theme, base;', 0);
|
||||
testing.expectEqual(0, index);
|
||||
testing.expectEqual(0, sheet.cssRules[0].type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_replaceSync>
|
||||
{
|
||||
// The cssRules path also fires through replaceSync.
|
||||
$('#dyn2').sheet.replaceSync(
|
||||
'@layer utilities { #dyn2-target { display: none; } }'
|
||||
);
|
||||
testing.expectFalse($('#dyn2-target').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=layer_deep_nesting_capped>
|
||||
{
|
||||
// 50 levels of `@layer a { ... }` around a `display:none` rule. The depth
|
||||
// guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
|
||||
// before the Zig stack is exhausted — and the inner rule sits beyond the
|
||||
// cap, so the element stays visible.
|
||||
let css = '';
|
||||
for (let i = 0; i < 50; i++) css += '@layer a { ';
|
||||
css += '#deep-nest-target { display: none; }';
|
||||
for (let i = 0; i < 50; i++) css += ' }';
|
||||
$('#deep-nest-style').sheet.replaceSync(css);
|
||||
testing.expectTrue($('#deep-nest-target').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
@@ -256,7 +256,7 @@
|
||||
<script id=cascade_deep_nesting_capped>
|
||||
{
|
||||
// 50 levels of `@media screen { ... }` around a `display:none` rule. The
|
||||
// depth guard (MAX_MEDIA_NESTING in StyleManager.zig) must short-circuit
|
||||
// depth guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
|
||||
// before the Zig stack is exhausted — and the inner rule sits beyond the
|
||||
// cap, so the element stays visible.
|
||||
let css = '';
|
||||
|
||||
@@ -67,6 +67,39 @@
|
||||
testing.expectEqual('DIV', div.tagName);
|
||||
}
|
||||
|
||||
{
|
||||
let constructorCalled = 0;
|
||||
|
||||
class TypedButton extends HTMLButtonElement {
|
||||
constructor() {
|
||||
super();
|
||||
constructorCalled++;
|
||||
this.custom = true;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('typed-button', TypedButton, { extends: 'button' });
|
||||
|
||||
const btn = document.createElement('button', { is: 'typed-button' });
|
||||
testing.expectEqual(1, constructorCalled);
|
||||
testing.expectEqual(true, btn instanceof TypedButton);
|
||||
testing.expectEqual(true, btn instanceof HTMLButtonElement);
|
||||
testing.expectEqual('BUTTON', btn.tagName);
|
||||
testing.expectEqual(true, btn.custom);
|
||||
}
|
||||
|
||||
// Directly constructing a built-in interface is unsupported and must throw a
|
||||
// TypeError (not silently succeed).
|
||||
{
|
||||
let threw = false;
|
||||
try {
|
||||
new HTMLDivElement();
|
||||
} catch (e) {
|
||||
threw = e instanceof TypeError;
|
||||
}
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
|
||||
{
|
||||
let connectedCount = 0;
|
||||
let disconnectedCount = 0;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id="constructor_sets_innerhtml">
|
||||
{
|
||||
// A custom element whose constructor sets innerHTML. The innerHTML
|
||||
// setter fragment-parses with this element as the parsing context.
|
||||
// html5ever's parse_fragment builds a throwaway context element of the
|
||||
// same tag via our create_element callback; if we run its constructor
|
||||
// it sets innerHTML again -> re-parses -> rebuilds the context element
|
||||
// -> constructs forever (stack overflow). The context element must be
|
||||
// built bare, so the constructor runs exactly once.
|
||||
let calls = 0;
|
||||
class CeCtorInnerHtml extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
this.innerHTML = '<span>hi</span>';
|
||||
}
|
||||
}
|
||||
customElements.define('ce-ctor-innerhtml', CeCtorInnerHtml);
|
||||
|
||||
const el = document.createElement('ce-ctor-innerhtml');
|
||||
testing.expectEqual(1, calls);
|
||||
testing.expectEqual('<span>hi</span>', el.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="innerhtml_on_custom_element_host">
|
||||
{
|
||||
// Setting innerHTML on an existing custom-element instance parses with
|
||||
// that element as context. The throwaway context element html5ever
|
||||
// builds must NOT invoke the constructor a second time.
|
||||
let calls = 0;
|
||||
class CeHost extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
}
|
||||
}
|
||||
customElements.define('ce-host', CeHost);
|
||||
|
||||
const host = document.createElement('ce-host');
|
||||
testing.expectEqual(1, calls);
|
||||
host.innerHTML = '<b>x</b>';
|
||||
testing.expectEqual(1, calls);
|
||||
testing.expectEqual('<b>x</b>', host.innerHTML);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="fragment_children_still_upgrade">
|
||||
{
|
||||
// Only the context element is skipped: real custom elements that appear
|
||||
// *inside* the parsed fragment must still be constructed.
|
||||
let calls = 0;
|
||||
class CeChild extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
calls += 1;
|
||||
}
|
||||
}
|
||||
customElements.define('ce-child', CeChild);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = '<ce-child></ce-child><ce-child></ce-child>';
|
||||
testing.expectEqual(2, calls);
|
||||
testing.expectEqual(2, container.querySelectorAll('ce-child').length);
|
||||
}
|
||||
</script>
|
||||
@@ -32,8 +32,7 @@
|
||||
|
||||
const unknownNsElement = document.createElementNS('http://example.com/unknown', 'custom');
|
||||
testing.expectEqual('custom', unknownNsElement.tagName);
|
||||
// Should be http://example.com/unknown
|
||||
testing.expectEqual('http://lightpanda.io/unsupported/namespace', unknownNsElement.namespaceURI);
|
||||
testing.expectEqual('http://example.com/unknown', unknownNsElement.namespaceURI);
|
||||
|
||||
const regularDiv = document.createElement('div');
|
||||
testing.expectEqual('DIV', regularDiv.tagName);
|
||||
@@ -45,5 +44,5 @@
|
||||
testing.expectEqual('te:ST', custom.tagName);
|
||||
testing.expectEqual('te', custom.prefix);
|
||||
testing.expectEqual('ST', custom.localName);
|
||||
testing.expectEqual('http://lightpanda.io/unsupported/namespace', custom.namespaceURI); // Should be test
|
||||
testing.expectEqual('test', custom.namespaceURI);
|
||||
</script>
|
||||
|
||||
@@ -319,3 +319,38 @@
|
||||
testing.expectEqual(0, blurCount);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="focus_display_none_noop">
|
||||
{
|
||||
// Per HTML spec §6.4.4: an element inside a display:none container
|
||||
// is "not being rendered" and therefore not focusable.
|
||||
const input1 = $('#input1');
|
||||
input1.focus();
|
||||
testing.expectEqual(input1, document.activeElement);
|
||||
|
||||
// Create a hidden container with a button
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.display = 'none';
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Hidden';
|
||||
wrapper.appendChild(btn);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
let focusCount = 0;
|
||||
btn.addEventListener('focus', () => focusCount++);
|
||||
|
||||
// focus() on the hidden button must be a no-op
|
||||
btn.focus();
|
||||
testing.expectEqual(input1, document.activeElement);
|
||||
testing.expectEqual(0, focusCount);
|
||||
|
||||
// Make it visible — now focus should work
|
||||
wrapper.style.display = '';
|
||||
btn.focus();
|
||||
testing.expectEqual(btn, document.activeElement);
|
||||
testing.expectEqual(1, focusCount);
|
||||
|
||||
// Cleanup
|
||||
wrapper.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<div class="nested">nested2</div>
|
||||
</div>
|
||||
|
||||
<div class="baaa">overlap1</div>
|
||||
|
||||
<script>
|
||||
const foos = document.getElementsByClassName('foo');
|
||||
</script>
|
||||
@@ -96,3 +98,10 @@
|
||||
testing.expectEqual('nested1', nested[0].textContent);
|
||||
testing.expectEqual('nested2', nested[1].textContent);
|
||||
</script>
|
||||
|
||||
<script id=overlappingSubstring>
|
||||
// "baaa" is a single class token; "aa" is a substring of it but not its own
|
||||
// token, so it must not match.
|
||||
const overlap = document.getElementsByClassName('aa');
|
||||
testing.expectEqual(0, overlap.length);
|
||||
</script>
|
||||
|
||||
@@ -221,8 +221,7 @@
|
||||
|
||||
const root = doc.documentElement;
|
||||
testing.expectEqual('prefix:localName', root.tagName);
|
||||
// TODO: Custom namespaces are being replaced with an empty value
|
||||
testing.expectEqual('http://lightpanda.io/unsupported/namespace', root.namespaceURI);
|
||||
testing.expectEqual('http://example.com', root.namespaceURI);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
155
src/browser/tests/domrect.html
Normal file
155
src/browser/tests/domrect.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>DOMRect Test</title>
|
||||
<script src="testing.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
<script id=default_construct>
|
||||
{
|
||||
const r = new DOMRect();
|
||||
testing.expectEqual(0, r.x);
|
||||
testing.expectEqual(0, r.y);
|
||||
testing.expectEqual(0, r.width);
|
||||
testing.expectEqual(0, r.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=construct_with_args>
|
||||
{
|
||||
const r = new DOMRect(1, 2, 3, 4);
|
||||
testing.expectEqual(1, r.x);
|
||||
testing.expectEqual(2, r.y);
|
||||
testing.expectEqual(3, r.width);
|
||||
testing.expectEqual(4, r.height);
|
||||
testing.expectEqual(2, r.top);
|
||||
testing.expectEqual(4, r.right);
|
||||
testing.expectEqual(6, r.bottom);
|
||||
testing.expectEqual(1, r.left);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=negative_dimensions>
|
||||
{
|
||||
// top/left take the min edge, right/bottom the max edge.
|
||||
const r = new DOMRect(10, 20, -4, -6);
|
||||
testing.expectEqual(14, r.top);
|
||||
testing.expectEqual(10, r.right);
|
||||
testing.expectEqual(20, r.bottom);
|
||||
testing.expectEqual(6, r.left);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=mutable_setters>
|
||||
{
|
||||
const r = new DOMRect(1, 1, 1, 1);
|
||||
r.x = 5;
|
||||
r.y = 6;
|
||||
r.width = 7;
|
||||
r.height = 8;
|
||||
testing.expectEqual(5, r.x);
|
||||
testing.expectEqual(6, r.y);
|
||||
testing.expectEqual(7, r.width);
|
||||
testing.expectEqual(8, r.height);
|
||||
// Inherited computed getters reflect the mutated values.
|
||||
testing.expectEqual(6, r.top);
|
||||
testing.expectEqual(12, r.right);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=readonly_is_immutable>
|
||||
{
|
||||
const ro = new DOMRectReadOnly(1, 2, 3, 4);
|
||||
testing.expectEqual(1, ro.x);
|
||||
// Assigning a readonly attribute is a silent no-op in non-strict mode.
|
||||
ro.x = 99;
|
||||
testing.expectEqual(1, ro.x);
|
||||
testing.expectEqual(3, ro.width);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=prototype_chain>
|
||||
{
|
||||
const r = new DOMRect(1, 2, 3, 4);
|
||||
testing.expectTrue(r instanceof DOMRect);
|
||||
testing.expectTrue(r instanceof DOMRectReadOnly);
|
||||
|
||||
const ro = new DOMRectReadOnly(1, 2, 3, 4);
|
||||
testing.expectTrue(ro instanceof DOMRectReadOnly);
|
||||
testing.expectFalse(ro instanceof DOMRect);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=from_rect>
|
||||
{
|
||||
const r = DOMRect.fromRect({ x: 5, y: 6, width: 7 });
|
||||
testing.expectTrue(r instanceof DOMRect);
|
||||
testing.expectEqual(5, r.x);
|
||||
testing.expectEqual(6, r.y);
|
||||
testing.expectEqual(7, r.width);
|
||||
testing.expectEqual(0, r.height);
|
||||
|
||||
const ro = DOMRectReadOnly.fromRect({ width: 10, height: 20 });
|
||||
testing.expectTrue(ro instanceof DOMRectReadOnly);
|
||||
testing.expectFalse(ro instanceof DOMRect);
|
||||
testing.expectEqual(10, ro.width);
|
||||
testing.expectEqual(20, ro.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=from_rect_empty>
|
||||
{
|
||||
const r = DOMRect.fromRect();
|
||||
testing.expectEqual(0, r.x);
|
||||
testing.expectEqual(0, r.y);
|
||||
testing.expectEqual(0, r.width);
|
||||
testing.expectEqual(0, r.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=to_json>
|
||||
{
|
||||
const r = new DOMRect(1, 2, 3, 4);
|
||||
const j = r.toJSON();
|
||||
testing.expectEqual(1, j.x);
|
||||
testing.expectEqual(2, j.y);
|
||||
testing.expectEqual(3, j.width);
|
||||
testing.expectEqual(4, j.height);
|
||||
testing.expectEqual(2, j.top);
|
||||
testing.expectEqual(4, j.right);
|
||||
testing.expectEqual(6, j.bottom);
|
||||
testing.expectEqual(1, j.left);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=structured_clone>
|
||||
{
|
||||
const r = structuredClone(new DOMRect(1, 2, 3, 4));
|
||||
testing.expectTrue(r instanceof DOMRect);
|
||||
testing.expectTrue(r instanceof DOMRectReadOnly);
|
||||
testing.expectEqual(1, r.x);
|
||||
testing.expectEqual(2, r.y);
|
||||
testing.expectEqual(3, r.width);
|
||||
testing.expectEqual(4, r.height);
|
||||
|
||||
const ro = structuredClone(new DOMRectReadOnly(5, 6, 7, 8));
|
||||
testing.expectTrue(ro instanceof DOMRectReadOnly);
|
||||
testing.expectFalse(ro instanceof DOMRect);
|
||||
testing.expectEqual(5, ro.x);
|
||||
testing.expectEqual(6, ro.y);
|
||||
testing.expectEqual(7, ro.width);
|
||||
testing.expectEqual(8, ro.height);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=bounding_client_rect>
|
||||
{
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
testing.expectTrue(r instanceof DOMRect);
|
||||
testing.expectTrue(r instanceof DOMRectReadOnly);
|
||||
}
|
||||
</script>
|
||||
68
src/browser/tests/element/html/area.html
Normal file
68
src/browser/tests/element/html/area.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<map name="m">
|
||||
<area id="a1" alt="logo" coords="0,0,82,126" shape="rect" target="_blank" download="pic" rel="noopener noreferrer" referrerpolicy="no-referrer" href="/page">
|
||||
<area id="a2">
|
||||
</map>
|
||||
|
||||
<script id="area-from-html">
|
||||
{
|
||||
const a1 = document.getElementById('a1');
|
||||
testing.expectEqual('HTMLAreaElement', a1.constructor.name);
|
||||
testing.expectEqual('logo', a1.alt);
|
||||
testing.expectEqual('0,0,82,126', a1.coords);
|
||||
testing.expectEqual('rect', a1.shape);
|
||||
testing.expectEqual('_blank', a1.target);
|
||||
testing.expectEqual('pic', a1.download);
|
||||
testing.expectEqual('noopener noreferrer', a1.rel);
|
||||
testing.expectEqual('no-referrer', a1.referrerPolicy);
|
||||
testing.expectEqual(2, a1.relList.length);
|
||||
testing.expectEqual('noopener', a1.relList.item(0));
|
||||
testing.expectEqual('noreferrer', a1.relList.item(1));
|
||||
testing.expectEqual(true, a1.relList.contains('noopener'));
|
||||
|
||||
const a2 = document.getElementById('a2');
|
||||
testing.expectEqual('', a2.alt);
|
||||
testing.expectEqual('', a2.coords);
|
||||
testing.expectEqual('', a2.shape);
|
||||
testing.expectEqual('', a2.target);
|
||||
testing.expectEqual('', a2.download);
|
||||
testing.expectEqual('', a2.rel);
|
||||
testing.expectEqual('', a2.referrerPolicy);
|
||||
testing.expectEqual(0, a2.relList.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="area-reflection">
|
||||
{
|
||||
const a = document.createElement('area');
|
||||
|
||||
a.alt = 'hero';
|
||||
testing.expectEqual('hero', a.getAttribute('alt'));
|
||||
a.setAttribute('alt', 'banner');
|
||||
testing.expectEqual('banner', a.alt);
|
||||
|
||||
a.coords = '1,2,3,4';
|
||||
testing.expectEqual('1,2,3,4', a.getAttribute('coords'));
|
||||
|
||||
a.shape = 'circle';
|
||||
testing.expectEqual('circle', a.getAttribute('shape'));
|
||||
|
||||
a.target = '_self';
|
||||
testing.expectEqual('_self', a.getAttribute('target'));
|
||||
|
||||
a.download = 'file.png';
|
||||
testing.expectEqual('file.png', a.getAttribute('download'));
|
||||
|
||||
a.rel = 'nofollow';
|
||||
testing.expectEqual('nofollow', a.getAttribute('rel'));
|
||||
testing.expectEqual('nofollow', a.relList.item(0));
|
||||
|
||||
// referrerPolicy reflects an enumerated attribute: unknown values map to ''
|
||||
a.referrerPolicy = 'origin';
|
||||
testing.expectEqual('origin', a.referrerPolicy);
|
||||
a.setAttribute('referrerpolicy', 'bogus');
|
||||
testing.expectEqual('', a.referrerPolicy);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../../testing.js"></script>
|
||||
|
||||
<!--
|
||||
Non-blocking consumers of <link rel=preload as=script>: async, defer, and
|
||||
dynamically-inserted scripts adopt the preloaded Script (takePreload in
|
||||
addFromElement) instead of fetching the URL a second time.
|
||||
|
||||
Each /serve-count/ URL is served by the test server with a body of
|
||||
`window.__serve_count_<name> = N;` where N counts serves of that URL — so the
|
||||
body the consumer executes records whether the preload was reused (1) or a
|
||||
duplicate fetch happened (2). The responses are Cache-Control: no-store, so
|
||||
the HTTP cache can't mask a duplicate fetch.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Adopted by a defer script. The parser doesn't pump HTTP between the link and
|
||||
the script tag, so the entry is still in flight at adoption: this exercises
|
||||
loading-adoption, where the transfer completes after the Script has been
|
||||
rewritten into a .frame defer script (PreloadedScript.doneCallback delegates
|
||||
to Script.doneCallback).
|
||||
-->
|
||||
<link rel="preload" as="script" href="/serve-count/defer.js" onload="window.link_defer_load = true">
|
||||
<script defer src="/serve-count/defer.js"></script>
|
||||
|
||||
<!-- Same, adopted by an async script. -->
|
||||
<link rel="preload" as="script" href="/serve-count/async.js" onload="window.link_async_load = true">
|
||||
<script async src="/serve-count/async.js"></script>
|
||||
|
||||
<!--
|
||||
Adopted by a dynamically-inserted script (defaults to async). The blocking
|
||||
empty.js fetch below pumps the HTTP client, so by insertion time the preload
|
||||
may be done (done-adoption: queued straight onto ready_scripts) or still in
|
||||
flight (loading-adoption) — both must produce the same result.
|
||||
-->
|
||||
<link rel="preload" as="script" href="/serve-count/dynamic.js" onload="window.link_dynamic_load = true">
|
||||
<script src="empty.js"></script>
|
||||
<script>
|
||||
const s = document.createElement('script');
|
||||
s.src = '/serve-count/dynamic.js';
|
||||
document.head.appendChild(s);
|
||||
</script>
|
||||
|
||||
<script id="checks">
|
||||
testing.onload(() => {
|
||||
// Every consumer executed the first (and only) serve of its URL.
|
||||
testing.expectEqual(1, window.__serve_count_defer);
|
||||
testing.expectEqual(1, window.__serve_count_async);
|
||||
testing.expectEqual(1, window.__serve_count_dynamic);
|
||||
// Adoption must not lose the hint links' load events (hint_element rides
|
||||
// on the Script, outside extra).
|
||||
testing.expectEqual(true, window.link_defer_load);
|
||||
testing.expectEqual(true, window.link_async_load);
|
||||
testing.expectEqual(true, window.link_dynamic_load);
|
||||
});
|
||||
</script>
|
||||
38
src/browser/tests/element/html/script/prescan.html
Normal file
38
src/browser/tests/element/html/script/prescan.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../../testing.js"></script>
|
||||
|
||||
<!--
|
||||
No <link> hints anywhere here: the preload scanner (Frame.prescanScripts)
|
||||
runs over the raw buffered HTML before parsing and starts every script
|
||||
download up front. Each consumer below must then reuse that fetch rather
|
||||
than issue its own.
|
||||
|
||||
/serve-count/ bodies record how many times the URL was served (see
|
||||
testing.zig); the body a consumer executes carries 1 when the prescan's
|
||||
fetch was reused, 2 when a duplicate fetch happened.
|
||||
-->
|
||||
|
||||
<!-- Blocking: waitForPreload consumes the prescan's in-flight fetch. -->
|
||||
<script src="/serve-count/prescan_blocking.js"></script>
|
||||
|
||||
<!-- Defer: takePreload adopts the prescan's Script. -->
|
||||
<script defer src="/serve-count/prescan_defer.js"></script>
|
||||
|
||||
<!--
|
||||
Module: the prescan parks module hints in imported_modules; the script
|
||||
element's own fetch adopts it via takeModuleHint.
|
||||
-->
|
||||
<script type="module" src="/serve-count/prescan_module.js"></script>
|
||||
|
||||
<script id="order">
|
||||
// The blocking script above executed in document order, before us.
|
||||
testing.expectEqual(1, window.__serve_count_prescan_blocking);
|
||||
</script>
|
||||
|
||||
<script id="checks">
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, window.__serve_count_prescan_blocking);
|
||||
testing.expectEqual(1, window.__serve_count_prescan_defer);
|
||||
testing.expectEqual(1, window.__serve_count_prescan_module);
|
||||
});
|
||||
</script>
|
||||
81
src/browser/tests/element/html/table.html
Normal file
81
src/browser/tests/element/html/table.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<script id="deleteRow">
|
||||
{
|
||||
// Built by hand so the tree order deliberately differs from the spec's
|
||||
// row order: the tfoot comes first and bare tr children sit between
|
||||
// the sections.
|
||||
const table = document.createElement('table');
|
||||
|
||||
const rows = {};
|
||||
const addRow = (parent, id) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = id;
|
||||
parent.appendChild(tr);
|
||||
rows[id] = tr;
|
||||
};
|
||||
const addSection = (tag, ...ids) => {
|
||||
const section = document.createElement(tag);
|
||||
for (const id of ids) addRow(section, id);
|
||||
table.appendChild(section);
|
||||
};
|
||||
|
||||
addSection('tfoot', 'foot1');
|
||||
addRow(table, 'top1');
|
||||
addSection('thead', 'head1', 'head2');
|
||||
addSection('tbody', 'body1');
|
||||
addRow(table, 'top2');
|
||||
addSection('tbody', 'body2');
|
||||
|
||||
// Spec order: thead rows first, then table-level tr and tbody rows in
|
||||
// tree order, then tfoot rows:
|
||||
// head1, head2, top1, body1, top2, body2, foot1
|
||||
|
||||
// -1 removes the last row in spec order: the tfoot row, even though
|
||||
// the tfoot is the table's first child.
|
||||
table.deleteRow(-1);
|
||||
testing.expectEqual(null, rows.foot1.parentNode);
|
||||
|
||||
// 6 rows left; out-of-range and < -1 both throw.
|
||||
testing.expectError('IndexSizeError', () => table.deleteRow(6));
|
||||
testing.expectError('IndexSizeError', () => table.deleteRow(-2));
|
||||
|
||||
// Index 3 of head1, head2, top1, body1, top2, body2 is the tbody row.
|
||||
table.deleteRow(3);
|
||||
testing.expectEqual(null, rows.body1.parentNode);
|
||||
|
||||
// deleteRow(0) drains the rest in spec order.
|
||||
for (const id of ['head1', 'head2', 'top1', 'top2', 'body2']) {
|
||||
testing.expectTrue(rows[id].parentNode !== null);
|
||||
table.deleteRow(0);
|
||||
testing.expectEqual(null, rows[id].parentNode);
|
||||
}
|
||||
|
||||
// On a rowless table, -1 is a no-op but anything else throws.
|
||||
table.deleteRow(-1);
|
||||
testing.expectError('IndexSizeError', () => table.deleteRow(0));
|
||||
}
|
||||
</script>
|
||||
|
||||
<table id="tbodies">
|
||||
<thead><tr></tr></thead>
|
||||
<tbody id="b1"><tr></tr></tbody>
|
||||
<tbody id="b2"><tr></tr></tbody>
|
||||
<tfoot><tr></tr></tfoot>
|
||||
</table>
|
||||
|
||||
<script id="tBodies">
|
||||
{
|
||||
const table = document.getElementById('tbodies');
|
||||
testing.expectEqual(2, table.tBodies.length);
|
||||
testing.expectEqual('b1', table.tBodies[0].id);
|
||||
testing.expectEqual('b2', table.tBodies[1].id);
|
||||
|
||||
// The collection is live.
|
||||
const bodies = table.tBodies;
|
||||
table.removeChild(document.getElementById('b1'));
|
||||
testing.expectEqual(1, bodies.length);
|
||||
testing.expectEqual('b2', bodies[0].id);
|
||||
}
|
||||
</script>
|
||||
73
src/browser/tests/element/svg/animated_string.html
Normal file
73
src/browser/tests/element/svg/animated_string.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<svg id=root class="chart wide">
|
||||
<use id=use1 xlink:href="#icon"></use>
|
||||
<use id=use2 href="#modern" xlink:href="#legacy"></use>
|
||||
<a id=link1></a>
|
||||
</svg>
|
||||
<div id=plain class="html-class"></div>
|
||||
|
||||
<script id=className>
|
||||
{
|
||||
const root = $('#root');
|
||||
testing.expectEqual('object', typeof root.className);
|
||||
testing.expectEqual(true, root.className instanceof SVGAnimatedString);
|
||||
testing.expectEqual('chart wide', root.className.baseVal);
|
||||
testing.expectEqual('chart wide', root.className.animVal);
|
||||
|
||||
// [SameObject]
|
||||
testing.expectEqual(true, root.className === root.className);
|
||||
|
||||
root.className.baseVal = 'chart narrow';
|
||||
testing.expectEqual('chart narrow', root.getAttribute('class'));
|
||||
testing.expectEqual('chart narrow', root.className.animVal);
|
||||
testing.expectEqual(true, root.classList.contains('narrow'));
|
||||
|
||||
root.setAttribute('class', 'other');
|
||||
testing.expectEqual('other', root.className.baseVal);
|
||||
|
||||
// The SVG className accessor doesn't leak onto HTML elements.
|
||||
testing.expectEqual('html-class', $('#plain').className);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=href>
|
||||
{
|
||||
// The parser stores xlink:href under its local name, href.
|
||||
const use1 = $('#use1');
|
||||
testing.expectEqual(true, use1.href instanceof SVGAnimatedString);
|
||||
testing.expectEqual('#icon', use1.href.baseVal);
|
||||
testing.expectEqual('#icon', use1.href.animVal);
|
||||
testing.expectEqual(true, use1.href === use1.href);
|
||||
testing.expectEqual('#modern', $('#use2').href.baseVal);
|
||||
|
||||
// Writes land in xlink:href in browsers (it's the attribute that's set)
|
||||
// but in href for us since the parser stored it there; assert the common
|
||||
// observable.
|
||||
use1.href.baseVal = '#other';
|
||||
testing.expectEqual('#other', use1.href.baseVal);
|
||||
|
||||
// setAttributeNS stores under the local name, so a runtime xlink:href
|
||||
// lands in href and reflects like the parsed variant. (Browsers keep the
|
||||
// xlink:href attribute but reflect it the same way.)
|
||||
const use3 = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use3.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#runtime');
|
||||
testing.expectEqual('#runtime', use3.href.baseVal);
|
||||
|
||||
// A namespace-less setAttribute('xlink:href') is a literal attribute in no
|
||||
// namespace: not reflected, in browsers or here.
|
||||
const use4 = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
||||
use4.setAttribute('xlink:href', '#literal');
|
||||
testing.expectEqual('', use4.href.baseVal);
|
||||
use4.href.baseVal = '#written';
|
||||
testing.expectEqual('#written', use4.getAttribute('href'));
|
||||
testing.expectEqual(true, use4.hasAttribute('xlink:href'));
|
||||
|
||||
// No href attribute at all: set writes to href.
|
||||
const link = $('#link1');
|
||||
testing.expectEqual('', link.href.baseVal);
|
||||
link.href.baseVal = '/docs';
|
||||
testing.expectEqual('/docs', link.getAttribute('href'));
|
||||
}
|
||||
</script>
|
||||
115
src/browser/tests/element/svg/hierarchy.html
Normal file
115
src/browser/tests/element/svg/hierarchy.html
Normal file
@@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<svg id=root width="200" height="100">
|
||||
<g id=group>
|
||||
<rect id=rect1 width="10" height="10"></rect>
|
||||
<circle id=circle1 r="5"></circle>
|
||||
<ellipse id=ellipse1></ellipse>
|
||||
<line id=line1></line>
|
||||
<polygon id=polygon1></polygon>
|
||||
<polyline id=polyline1></polyline>
|
||||
</g>
|
||||
<path id=path1 d="M 0 0 L 10 10"></path>
|
||||
<a id=link1></a>
|
||||
<use id=use1></use>
|
||||
<image id=image1></image>
|
||||
<defs id=defs1></defs>
|
||||
<filter id=filter1></filter>
|
||||
</svg>
|
||||
|
||||
<script id=classes>
|
||||
{
|
||||
testing.expectEqual('function', typeof SVGElement);
|
||||
testing.expectEqual('function', typeof SVGGraphicsElement);
|
||||
testing.expectEqual('function', typeof SVGGeometryElement);
|
||||
testing.expectEqual('function', typeof SVGSVGElement);
|
||||
testing.expectEqual('function', typeof SVGGElement);
|
||||
testing.expectEqual('function', typeof SVGAElement);
|
||||
testing.expectEqual('function', typeof SVGUseElement);
|
||||
testing.expectEqual('function', typeof SVGImageElement);
|
||||
testing.expectEqual('function', typeof SVGDefsElement);
|
||||
testing.expectEqual('function', typeof SVGRectElement);
|
||||
testing.expectEqual('function', typeof SVGCircleElement);
|
||||
testing.expectEqual('function', typeof SVGEllipseElement);
|
||||
testing.expectEqual('function', typeof SVGLineElement);
|
||||
testing.expectEqual('function', typeof SVGPathElement);
|
||||
testing.expectEqual('function', typeof SVGPolygonElement);
|
||||
testing.expectEqual('function', typeof SVGPolylineElement);
|
||||
testing.expectEqual('function', typeof SVGAnimatedString);
|
||||
testing.expectEqual('function', typeof SVGNumber);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=instanceof>
|
||||
{
|
||||
const root = $('#root');
|
||||
testing.expectEqual(true, root instanceof SVGSVGElement);
|
||||
testing.expectEqual(true, root instanceof SVGGraphicsElement);
|
||||
testing.expectEqual(true, root instanceof SVGElement);
|
||||
testing.expectEqual(true, root instanceof Element);
|
||||
testing.expectEqual(false, root instanceof SVGGeometryElement);
|
||||
testing.expectEqual('svg', root.tagName);
|
||||
|
||||
const rect = $('#rect1');
|
||||
testing.expectEqual(true, rect instanceof SVGRectElement);
|
||||
testing.expectEqual(true, rect instanceof SVGGeometryElement);
|
||||
testing.expectEqual(true, rect instanceof SVGGraphicsElement);
|
||||
testing.expectEqual(true, rect instanceof SVGElement);
|
||||
testing.expectEqual(false, rect instanceof SVGSVGElement);
|
||||
testing.expectEqual('rect', rect.tagName);
|
||||
testing.expectEqual('10', rect.getAttribute('width'));
|
||||
|
||||
testing.expectEqual(true, $('#group') instanceof SVGGElement);
|
||||
testing.expectEqual(true, $('#circle1') instanceof SVGCircleElement);
|
||||
testing.expectEqual(true, $('#ellipse1') instanceof SVGEllipseElement);
|
||||
testing.expectEqual(true, $('#line1') instanceof SVGLineElement);
|
||||
testing.expectEqual(true, $('#polygon1') instanceof SVGPolygonElement);
|
||||
testing.expectEqual(true, $('#polyline1') instanceof SVGPolylineElement);
|
||||
testing.expectEqual(true, $('#path1') instanceof SVGPathElement);
|
||||
testing.expectEqual(true, $('#link1') instanceof SVGAElement);
|
||||
testing.expectEqual(true, $('#use1') instanceof SVGUseElement);
|
||||
testing.expectEqual(true, $('#image1') instanceof SVGImageElement);
|
||||
testing.expectEqual(true, $('#defs1') instanceof SVGDefsElement);
|
||||
|
||||
// Elements without a dedicated type stay generic, but are still SVGElement.
|
||||
const filter = $('#filter1');
|
||||
testing.expectEqual(true, filter instanceof SVGElement);
|
||||
testing.expectEqual(false, filter instanceof SVGGraphicsElement);
|
||||
testing.expectEqual('filter', filter.tagName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=createElementNS>
|
||||
{
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const rect = document.createElementNS(NS, 'rect');
|
||||
testing.expectEqual(true, rect instanceof SVGRectElement);
|
||||
testing.expectEqual('rect', rect.tagName);
|
||||
|
||||
// SVG tag names are case-sensitive: 'RECT' isn't a rect element.
|
||||
const notRect = document.createElementNS(NS, 'RECT');
|
||||
testing.expectEqual(false, notRect instanceof SVGRectElement);
|
||||
testing.expectEqual(true, notRect instanceof SVGElement);
|
||||
testing.expectEqual('RECT', notRect.tagName);
|
||||
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
testing.expectEqual(true, svg instanceof SVGSVGElement);
|
||||
|
||||
// querySelector still matches both dedicated and generic types.
|
||||
testing.expectEqual($('#rect1'), document.querySelector('#root rect'));
|
||||
testing.expectEqual($('#use1'), document.querySelector('#root use'));
|
||||
testing.expectEqual($('#filter1'), document.querySelector('#root filter'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=hasAttributeNS>
|
||||
{
|
||||
// Parser-created attributes have a null namespace. We ignore attribute
|
||||
// namespaces, so only null/'' lookups behave like a real browser here.
|
||||
const rect = $('#rect1');
|
||||
testing.expectEqual(true, rect.hasAttributeNS(null, 'width'));
|
||||
testing.expectEqual(true, rect.hasAttributeNS('', 'width'));
|
||||
testing.expectEqual(false, rect.hasAttributeNS(null, 'nope'));
|
||||
}
|
||||
</script>
|
||||
85
src/browser/tests/element/svg/svgsvg.html
Normal file
85
src/browser/tests/element/svg/svgsvg.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<svg id=first>
|
||||
<g>
|
||||
<rect id=inner-rect></rect>
|
||||
<svg id=nested>
|
||||
<circle id=nested-circle></circle>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
<svg id=second>
|
||||
<rect id=other-rect></rect>
|
||||
</svg>
|
||||
|
||||
<script id=getElementById>
|
||||
{
|
||||
const first = $('#first');
|
||||
testing.expectEqual($('#inner-rect'), first.getElementById('inner-rect'));
|
||||
testing.expectEqual($('#nested-circle'), first.getElementById('nested-circle'));
|
||||
|
||||
// Scoped to the subtree, unlike document.getElementById.
|
||||
testing.expectEqual(null, first.getElementById('other-rect'));
|
||||
testing.expectEqual(null, $('#second').getElementById('inner-rect'));
|
||||
testing.expectEqual(null, first.getElementById(''));
|
||||
testing.expectEqual(null, first.getElementById('missing'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=factories>
|
||||
{
|
||||
// SVG2 defines SVGPoint/SVGMatrix/SVGRect as aliases of the DOM geometry
|
||||
// types, which is what we return. Browsers (Chrome and Firefox both) still
|
||||
// ship distinct legacy interfaces, so the instanceof and DOM-geometry-only
|
||||
// members (w, is2D) fail there. The DOM types are functional supersets of
|
||||
// the legacy ones, so site-facing behavior only gains members.
|
||||
const svg = $('#first');
|
||||
|
||||
const point = svg.createSVGPoint();
|
||||
testing.expectEqual(true, point instanceof DOMPoint);
|
||||
testing.expectEqual(0, point.x);
|
||||
testing.expectEqual(0, point.y);
|
||||
testing.expectEqual(1, point.w);
|
||||
point.x = 5;
|
||||
testing.expectEqual(5, point.x);
|
||||
|
||||
const matrix = svg.createSVGMatrix();
|
||||
testing.expectEqual(true, matrix instanceof DOMMatrix);
|
||||
testing.expectEqual(1, matrix.a);
|
||||
testing.expectEqual(0, matrix.b);
|
||||
testing.expectEqual(1, matrix.d);
|
||||
testing.expectEqual(true, matrix.is2D);
|
||||
|
||||
const rect = svg.createSVGRect();
|
||||
testing.expectEqual(true, rect instanceof DOMRect);
|
||||
testing.expectEqual(0, rect.x);
|
||||
testing.expectEqual(0, rect.width);
|
||||
|
||||
const number = svg.createSVGNumber();
|
||||
testing.expectEqual(true, number instanceof SVGNumber);
|
||||
testing.expectEqual(0, number.value);
|
||||
number.value = 4.5;
|
||||
testing.expectEqual(4.5, number.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=owner>
|
||||
{
|
||||
const first = $('#first');
|
||||
const nested = $('#nested');
|
||||
|
||||
testing.expectEqual(null, first.ownerSVGElement);
|
||||
testing.expectEqual(null, first.viewportElement);
|
||||
|
||||
testing.expectEqual(first, $('#inner-rect').ownerSVGElement);
|
||||
testing.expectEqual(first, $('#inner-rect').viewportElement);
|
||||
testing.expectEqual(first, nested.ownerSVGElement);
|
||||
testing.expectEqual(nested, $('#nested-circle').ownerSVGElement);
|
||||
testing.expectEqual($('#second'), $('#other-rect').ownerSVGElement);
|
||||
|
||||
// A foreignObject starts a new SVG document fragment.
|
||||
first.innerHTML += '<foreignObject><div><svg id=foreign-svg></svg></div></foreignObject>';
|
||||
testing.expectEqual(null, $('#foreign-svg').ownerSVGElement);
|
||||
}
|
||||
</script>
|
||||
@@ -11,7 +11,8 @@
|
||||
testing.expectEqual("test", event.type);
|
||||
testing.expectEqual("", event.data);
|
||||
testing.expectEqual(0, event.detail);
|
||||
testing.expectEqual(window, event.view);
|
||||
// per UIEventInit, view defaults to null for synthetic events
|
||||
testing.expectEqual(null, event.view);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -75,8 +75,10 @@
|
||||
const event2 = document.createEvent('CUSTOMEVENT');
|
||||
testing.expectEqual('', event2.type);
|
||||
|
||||
const event3 = document.createEvent('CustomEvents');
|
||||
testing.expectEqual('', event3.type);
|
||||
// per spec, the pluralized legacy alias is not supported
|
||||
let threw = false;
|
||||
try { document.createEvent('CustomEvents'); } catch (e) { threw = e.name === 'NotSupportedError'; }
|
||||
testing.expectEqual(true, threw);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
testing.expectEqual(5, evt.detail);
|
||||
testing.expectEqual(true, evt.bubbles);
|
||||
testing.expectEqual(true, evt.cancelable);
|
||||
testing.expectEqual(window, evt.view);
|
||||
testing.expectEqual(null, evt.view);
|
||||
</script>
|
||||
|
||||
<script id=uiEventWithView>
|
||||
@@ -30,7 +30,7 @@
|
||||
testing.expectEqual(0, evt3.detail);
|
||||
testing.expectEqual(false, evt3.bubbles);
|
||||
testing.expectEqual(false, evt3.cancelable);
|
||||
testing.expectEqual(window, evt3.view);
|
||||
testing.expectEqual(null, evt3.view);
|
||||
</script>
|
||||
|
||||
<script id=uiEventInheritance>
|
||||
|
||||
@@ -900,3 +900,56 @@
|
||||
window.removeEventListener('win_throw', c);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=return_false_parent><span id=return_false_child></span></div>
|
||||
<script id=inlineHandlerReturnFalseCancels>
|
||||
// Per the HTML event handler processing algorithm, an event handler (on*
|
||||
// property/attribute) returning false cancels the event; the return value
|
||||
// of addEventListener listeners is ignored.
|
||||
{
|
||||
const parent = $('#return_false_parent');
|
||||
const child = $('#return_false_child');
|
||||
|
||||
// At-target inline handler.
|
||||
child.onclick = () => false;
|
||||
const ev1 = new MouseEvent('click', {bubbles: true, cancelable: true});
|
||||
testing.expectEqual(false, child.dispatchEvent(ev1));
|
||||
testing.expectEqual(true, ev1.defaultPrevented);
|
||||
child.onclick = null;
|
||||
|
||||
// Inline handler on an ancestor (bubble phase).
|
||||
parent.onclick = () => false;
|
||||
const ev2 = new MouseEvent('click', {bubbles: true, cancelable: true});
|
||||
testing.expectEqual(false, child.dispatchEvent(ev2));
|
||||
testing.expectEqual(true, ev2.defaultPrevented);
|
||||
parent.onclick = null;
|
||||
|
||||
// Only false cancels; other falsy values don't.
|
||||
child.onclick = () => undefined;
|
||||
const ev3 = new MouseEvent('click', {bubbles: true, cancelable: true});
|
||||
testing.expectEqual(true, child.dispatchEvent(ev3));
|
||||
testing.expectEqual(false, ev3.defaultPrevented);
|
||||
child.onclick = null;
|
||||
|
||||
// addEventListener listeners returning false don't cancel.
|
||||
const listener = () => false;
|
||||
child.addEventListener('click', listener);
|
||||
const ev4 = new MouseEvent('click', {bubbles: true, cancelable: true});
|
||||
testing.expectEqual(true, child.dispatchEvent(ev4));
|
||||
testing.expectEqual(false, ev4.defaultPrevented);
|
||||
child.removeEventListener('click', listener);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=beforeUnloadEventReturnValue>
|
||||
{
|
||||
const event = document.createEvent('BeforeUnloadEvent');
|
||||
testing.expectEqual('', event.returnValue);
|
||||
|
||||
event.returnValue = 'first';
|
||||
testing.expectEqual('first', event.returnValue);
|
||||
|
||||
event.returnValue = 'replacement';
|
||||
testing.expectEqual('replacement', event.returnValue);
|
||||
}
|
||||
</script>
|
||||
|
||||
6
src/browser/tests/frames/support/window_reflecting.html
Normal file
6
src/browser/tests/frames/support/window_reflecting.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- The onresize attribute exercises the parse-time path: it must land on
|
||||
THIS frame's window, not the top frame's. -->
|
||||
<body onresize="window.__attr_resize = true">
|
||||
<p>x</p>
|
||||
</body>
|
||||
53
src/browser/tests/frames/window_reflecting_handlers.html
Normal file
53
src/browser/tests/frames/window_reflecting_handlers.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<head></head>
|
||||
<body>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<!--
|
||||
The window-reflecting body element event handlers (onblur, onerror, onfocus,
|
||||
onload, onresize, onscroll) alias the Window of the body's node document. A
|
||||
same-origin script reaching into another frame (parent setting a handler on
|
||||
an iframe's body) must target the iframe's window, not the window of the
|
||||
realm running the assignment.
|
||||
-->
|
||||
<iframe id="wrh" src="support/window_reflecting.html"></iframe>
|
||||
|
||||
<script id="cross_realm_property_handlers">
|
||||
testing.onload(() => {
|
||||
const idf = document.getElementById('wrh');
|
||||
const ibody = idf.contentDocument.body;
|
||||
const iwin = idf.contentWindow;
|
||||
|
||||
const cb = () => {};
|
||||
ibody.onblur = cb;
|
||||
testing.expectEqual(cb, ibody.onblur);
|
||||
testing.expectEqual(cb, iwin.onblur);
|
||||
// the parent's window (the caller's realm) must be untouched
|
||||
testing.expectTrue(window.onblur == null);
|
||||
testing.expectTrue(document.body.onblur == null);
|
||||
|
||||
ibody.onblur = null;
|
||||
testing.expectTrue(iwin.onblur == null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id="cross_realm_attribute_handlers">
|
||||
testing.onload(() => {
|
||||
const idf = document.getElementById('wrh');
|
||||
const ibody = idf.contentDocument.body;
|
||||
const iwin = idf.contentWindow;
|
||||
|
||||
// Parse-time: the onresize attribute in the iframe's markup was compiled
|
||||
// onto the iframe's window while the parent frame was the active one.
|
||||
testing.expectEqual('function', typeof iwin.onresize);
|
||||
testing.expectTrue(window.onresize == null);
|
||||
|
||||
// Runtime: setting the content attribute from the parent realm.
|
||||
ibody.setAttribute('onscroll', 'window.__parent_set = true');
|
||||
testing.expectEqual('function', typeof iwin.onscroll);
|
||||
testing.expectTrue(window.onscroll == null);
|
||||
|
||||
ibody.removeAttribute('onscroll');
|
||||
testing.expectTrue(iwin.onscroll == null);
|
||||
});
|
||||
</script>
|
||||
@@ -70,6 +70,19 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=structured-clone>
|
||||
{
|
||||
const img = new ImageData(2, 2);
|
||||
img.data[0] = 42;
|
||||
const clone = structuredClone(img);
|
||||
testing.expectEqual(2, clone.width);
|
||||
testing.expectEqual(2, clone.height);
|
||||
testing.expectEqual(42, clone.data[0]);
|
||||
clone.data[0] = 7;
|
||||
testing.expectEqual(42, img.data[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=too-large>
|
||||
testing.expectError("IndexSizeError", () => new ImageData(2_147_483_648, 2_147_483_648));
|
||||
</script>
|
||||
|
||||
@@ -183,31 +183,113 @@
|
||||
replaceParent.replaceChild(replaceNew, replaceOld);
|
||||
|
||||
await state.done((mutations) => {
|
||||
// replaceChild generates two separate mutation records in modern spec:
|
||||
// 1. First record for insertBefore (new node added)
|
||||
// 2. Second record for removeChild (old node removed)
|
||||
testing.expectEqual(2, mutations.length);
|
||||
// Per the DOM "replace" algorithm, replaceChild queues one combined
|
||||
// mutation record with both the added and the removed node.
|
||||
testing.expectEqual(1, mutations.length);
|
||||
|
||||
// First mutation: insertion of new node
|
||||
testing.expectEqual('childList', mutations[0].type);
|
||||
testing.expectEqual(replaceParent, mutations[0].target);
|
||||
testing.expectEqual(1, mutations[0].addedNodes.length);
|
||||
testing.expectEqual(replaceNew, mutations[0].addedNodes[0]);
|
||||
testing.expectEqual(0, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(1, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(replaceOld, mutations[0].removedNodes[0]);
|
||||
testing.expectEqual(null, mutations[0].previousSibling);
|
||||
testing.expectEqual(replaceOld, mutations[0].nextSibling);
|
||||
testing.expectEqual(null, mutations[0].nextSibling);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="replace-frag-parent"><div id="replace-frag-old">Old</div></div>
|
||||
|
||||
<script id="childlist_replace_child_fragment" type=module>
|
||||
const state = await testing.async();
|
||||
const parent = document.getElementById('replace-frag-parent');
|
||||
const oldChild = document.getElementById('replace-frag-old');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const frag1 = document.createElement('div');
|
||||
const frag2 = document.createElement('div');
|
||||
fragment.append(frag1, frag2);
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
observer.disconnect();
|
||||
state.resolve(records);
|
||||
});
|
||||
observer.observe(parent, { childList: true });
|
||||
observer.observe(fragment, { childList: true });
|
||||
|
||||
parent.replaceChild(fragment, oldChild);
|
||||
|
||||
await state.done((mutations) => {
|
||||
// The fragment's removal record is queued even though the replacement
|
||||
// itself suppresses observers (the spec's insert algorithm notes the
|
||||
// fragment record "intentionally does not pay attention to
|
||||
// suppressObservers"), followed by the parent's combined record.
|
||||
testing.expectEqual(2, mutations.length);
|
||||
|
||||
testing.expectEqual('childList', mutations[0].type);
|
||||
testing.expectEqual(fragment, mutations[0].target);
|
||||
testing.expectEqual(0, mutations[0].addedNodes.length);
|
||||
testing.expectEqual(2, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(frag1, mutations[0].removedNodes[0]);
|
||||
testing.expectEqual(frag2, mutations[0].removedNodes[1]);
|
||||
testing.expectEqual(null, mutations[0].previousSibling);
|
||||
testing.expectEqual(null, mutations[0].nextSibling);
|
||||
|
||||
// Second mutation: removal of old node
|
||||
testing.expectEqual('childList', mutations[1].type);
|
||||
testing.expectEqual(replaceParent, mutations[1].target);
|
||||
testing.expectEqual(0, mutations[1].addedNodes.length);
|
||||
testing.expectEqual(parent, mutations[1].target);
|
||||
testing.expectEqual(2, mutations[1].addedNodes.length);
|
||||
testing.expectEqual(frag1, mutations[1].addedNodes[0]);
|
||||
testing.expectEqual(frag2, mutations[1].addedNodes[1]);
|
||||
testing.expectEqual(1, mutations[1].removedNodes.length);
|
||||
testing.expectEqual(replaceOld, mutations[1].removedNodes[0]);
|
||||
testing.expectEqual(replaceNew, mutations[1].previousSibling);
|
||||
testing.expectEqual(oldChild, mutations[1].removedNodes[0]);
|
||||
testing.expectEqual(null, mutations[1].previousSibling);
|
||||
testing.expectEqual(null, mutations[1].nextSibling);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="replace-adjacent-parent"><div id="replace-adjacent-a">A</div><div id="replace-adjacent-new">New</div><div id="replace-adjacent-old">Old</div><div id="replace-adjacent-b">B</div></div>
|
||||
|
||||
<script id="childlist_replace_child_adjacent" type=module>
|
||||
const state = await testing.async();
|
||||
const parent = document.getElementById('replace-adjacent-parent');
|
||||
const a = document.getElementById('replace-adjacent-a');
|
||||
const newChild = document.getElementById('replace-adjacent-new');
|
||||
const oldChild = document.getElementById('replace-adjacent-old');
|
||||
const b = document.getElementById('replace-adjacent-b');
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
observer.disconnect();
|
||||
state.resolve(records);
|
||||
});
|
||||
observer.observe(parent, { childList: true });
|
||||
|
||||
// Replacing a child with its immediate previous sibling: the combined
|
||||
// record's previousSibling is captured before newChild is removed from
|
||||
// its old position, so it is newChild itself.
|
||||
parent.replaceChild(newChild, oldChild);
|
||||
|
||||
await state.done((mutations) => {
|
||||
testing.expectEqual(2, mutations.length);
|
||||
|
||||
testing.expectEqual('childList', mutations[0].type);
|
||||
testing.expectEqual(parent, mutations[0].target);
|
||||
testing.expectEqual(0, mutations[0].addedNodes.length);
|
||||
testing.expectEqual(1, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(newChild, mutations[0].removedNodes[0]);
|
||||
testing.expectEqual(a, mutations[0].previousSibling);
|
||||
testing.expectEqual(oldChild, mutations[0].nextSibling);
|
||||
|
||||
testing.expectEqual('childList', mutations[1].type);
|
||||
testing.expectEqual(parent, mutations[1].target);
|
||||
testing.expectEqual(1, mutations[1].addedNodes.length);
|
||||
testing.expectEqual(newChild, mutations[1].addedNodes[0]);
|
||||
testing.expectEqual(1, mutations[1].removedNodes.length);
|
||||
testing.expectEqual(oldChild, mutations[1].removedNodes[0]);
|
||||
testing.expectEqual(newChild, mutations[1].previousSibling);
|
||||
testing.expectEqual(b, mutations[1].nextSibling);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="multiple-parent"></div>
|
||||
|
||||
<script id="childlist_multiple_mutations" type=module>
|
||||
@@ -269,41 +351,18 @@
|
||||
innerHtmlParent.innerHTML = '<span>New 1</span><span>New 2</span><span>New 3</span>';
|
||||
|
||||
await state.done((mutations) => {
|
||||
// innerHTML triggers mutations for both removals and additions
|
||||
// With tri-state: from_parser=true + parse_mode=fragment -> mutations fire
|
||||
// HTML wrapper element is filtered out, so: 3 removals + 3 additions = 6
|
||||
testing.expectEqual(6, mutations.length);
|
||||
// Per the "replace all" algorithm, innerHTML queues a single mutation
|
||||
// record combining all removed and added nodes.
|
||||
testing.expectEqual(1, mutations.length);
|
||||
|
||||
// First 3: removals
|
||||
testing.expectEqual('childList', mutations[0].type);
|
||||
testing.expectEqual(1, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(3, mutations[0].removedNodes.length);
|
||||
testing.expectEqual(oldChildren[0], mutations[0].removedNodes[0]);
|
||||
testing.expectEqual(0, mutations[0].addedNodes.length);
|
||||
|
||||
testing.expectEqual('childList', mutations[1].type);
|
||||
testing.expectEqual(1, mutations[1].removedNodes.length);
|
||||
testing.expectEqual(oldChildren[1], mutations[1].removedNodes[0]);
|
||||
testing.expectEqual(0, mutations[1].addedNodes.length);
|
||||
|
||||
testing.expectEqual('childList', mutations[2].type);
|
||||
testing.expectEqual(1, mutations[2].removedNodes.length);
|
||||
testing.expectEqual(oldChildren[2], mutations[2].removedNodes[0]);
|
||||
testing.expectEqual(0, mutations[2].addedNodes.length);
|
||||
|
||||
// Last 3: additions (unwrapped span elements)
|
||||
testing.expectEqual('childList', mutations[3].type);
|
||||
testing.expectEqual(0, mutations[3].removedNodes.length);
|
||||
testing.expectEqual(1, mutations[3].addedNodes.length);
|
||||
testing.expectEqual('SPAN', mutations[3].addedNodes[0].nodeName);
|
||||
|
||||
testing.expectEqual('childList', mutations[4].type);
|
||||
testing.expectEqual(0, mutations[4].removedNodes.length);
|
||||
testing.expectEqual(1, mutations[4].addedNodes.length);
|
||||
testing.expectEqual('SPAN', mutations[4].addedNodes[0].nodeName);
|
||||
|
||||
testing.expectEqual('childList', mutations[5].type);
|
||||
testing.expectEqual(0, mutations[5].removedNodes.length);
|
||||
testing.expectEqual(1, mutations[5].addedNodes.length);
|
||||
testing.expectEqual('SPAN', mutations[5].addedNodes[0].nodeName);
|
||||
testing.expectEqual(oldChildren[1], mutations[0].removedNodes[1]);
|
||||
testing.expectEqual(oldChildren[2], mutations[0].removedNodes[2]);
|
||||
testing.expectEqual(3, mutations[0].addedNodes.length);
|
||||
testing.expectEqual('SPAN', mutations[0].addedNodes[0].nodeName);
|
||||
testing.expectEqual('SPAN', mutations[0].addedNodes[1].nodeName);
|
||||
testing.expectEqual('SPAN', mutations[0].addedNodes[2].nodeName);
|
||||
});
|
||||
</script>
|
||||
|
||||
18
src/browser/tests/net/eventsource-worker.js
Normal file
18
src/browser/tests/net/eventsource-worker.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Exercises EventSource inside a worker. Receives a command from the page,
|
||||
// consumes the stream, and posts the results back.
|
||||
self.onmessage = function() {
|
||||
try {
|
||||
const received = [];
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/simple');
|
||||
es.onopen = () => received.push('open');
|
||||
es.onmessage = (e) => received.push(e.data);
|
||||
es.addEventListener('custom', (e) => {
|
||||
received.push(e.data);
|
||||
es.close();
|
||||
postMessage({ ok: true, received, readyState: es.readyState });
|
||||
});
|
||||
es.onerror = () => postMessage({ ok: false, err: 'eventsource error' });
|
||||
} catch (err) {
|
||||
postMessage({ ok: false, err: String(err) });
|
||||
}
|
||||
};
|
||||
167
src/browser/tests/net/eventsource.html
Normal file
167
src/browser/tests/net/eventsource.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=constants type=module>
|
||||
testing.expectEqual(0, EventSource.CONNECTING);
|
||||
testing.expectEqual(1, EventSource.OPEN);
|
||||
testing.expectEqual(2, EventSource.CLOSED);
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
new EventSource('http://this is invalid/');
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
testing.expectEqual('SyntaxError', e.name);
|
||||
}
|
||||
testing.expectTrue(threw, 'invalid URL must throw');
|
||||
</script>
|
||||
|
||||
<script id=simple type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const received = [];
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/simple');
|
||||
testing.expectEqual(0, es.readyState);
|
||||
testing.expectEqual('http://127.0.0.1:9582/sse/simple', es.url);
|
||||
testing.expectEqual(false, es.withCredentials);
|
||||
|
||||
es.onopen = () => {
|
||||
received.push(['open', es.readyState]);
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
received.push(['message', e.data, e.lastEventId, e.origin, e.isTrusted]);
|
||||
};
|
||||
es.addEventListener('custom', (e) => {
|
||||
received.push(['custom', e.data, e.lastEventId]);
|
||||
es.close();
|
||||
received.push(['closed', es.readyState]);
|
||||
state.resolve();
|
||||
});
|
||||
|
||||
await state.done(() => {
|
||||
testing.expectEqual([
|
||||
['open', 1],
|
||||
['message', 'first', '', 'http://127.0.0.1:9582', true],
|
||||
['custom', 'a\nb', '42'],
|
||||
['closed', 2],
|
||||
], received);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=relative_url type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const es = new EventSource('/sse/simple');
|
||||
testing.expectEqual('http://127.0.0.1:9582/sse/simple', es.url);
|
||||
es.onerror = () => { throw new Error('unexpected error'); };
|
||||
es.onmessage = (e) => {
|
||||
es.close();
|
||||
state.resolve(e.data);
|
||||
};
|
||||
|
||||
await state.done((data) => {
|
||||
testing.expectEqual('first', data);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=streaming type=module>
|
||||
{
|
||||
// The server only sends "second" after seeing our /sse/flag request, which
|
||||
// we make on receiving "first" — so this only completes if data events are
|
||||
// delivered while the response is still streaming.
|
||||
const state = await testing.async();
|
||||
const received = [];
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/streaming');
|
||||
es.onmessage = (e) => {
|
||||
received.push(e.data);
|
||||
if (e.data === 'first') {
|
||||
fetch('http://127.0.0.1:9582/sse/flag');
|
||||
} else {
|
||||
es.close();
|
||||
state.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
await state.done(() => {
|
||||
testing.expectEqual(['first', 'second'], received);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=reconnect type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const received = [];
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/reconnect');
|
||||
es.onerror = () => {
|
||||
received.push(['error', es.readyState]);
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
received.push(e.data);
|
||||
if (e.data === 'two') {
|
||||
es.close();
|
||||
state.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
await state.done(() => {
|
||||
testing.expectEqual(['one', ['error', 0], 'two'], received);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=pending_reconnect_at_teardown type=module>
|
||||
{
|
||||
// Deliberately left open: the stream ends and the source sits on its
|
||||
// (60s) reconnect timer with no transfer, so page teardown can only
|
||||
// clean it up through the scheduler-task finalizer. The test runner's
|
||||
// leak detection fails this test if that path regresses.
|
||||
const state = await testing.async();
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/long_retry');
|
||||
es.onerror = () => state.resolve(es.readyState);
|
||||
|
||||
await state.done((readyState) => {
|
||||
testing.expectEqual(0, readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=fail_status type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/404');
|
||||
es.onmessage = () => { throw new Error('unexpected message'); };
|
||||
es.onerror = () => state.resolve(es.readyState);
|
||||
|
||||
await state.done((readyState) => {
|
||||
testing.expectEqual(2, readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=fail_mime type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const es = new EventSource('http://127.0.0.1:9582/sse/badmime');
|
||||
es.onmessage = () => { throw new Error('unexpected message'); };
|
||||
es.onerror = () => state.resolve(es.readyState);
|
||||
|
||||
await state.done((readyState) => {
|
||||
testing.expectEqual(2, readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=fail_scheme type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const es = new EventSource('ftp://127.0.0.1/');
|
||||
es.onerror = () => state.resolve(es.readyState);
|
||||
|
||||
await state.done((readyState) => {
|
||||
testing.expectEqual(2, readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
17
src/browser/tests/net/eventsource_worker.html
Normal file
17
src/browser/tests/net/eventsource_worker.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=eventsource_in_worker type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
const worker = new Worker('./eventsource-worker.js');
|
||||
worker.onmessage = (e) => state.resolve(e.data);
|
||||
worker.postMessage({});
|
||||
|
||||
await state.done((data) => {
|
||||
testing.expectTrue(data.ok, 'worker eventsource error: ' + data.err);
|
||||
testing.expectEqual(['open', 'first', 'a\nb'], data.received);
|
||||
testing.expectEqual(2, data.readyState);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -562,6 +562,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=close_while_connecting type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
let received = [];
|
||||
|
||||
let ws = new WebSocket('ws://127.0.0.1:9584/');
|
||||
ws.addEventListener('open', () => { received.push('open'); });
|
||||
ws.addEventListener('close', (e) => {
|
||||
received.push(['close', e.code, e.reason, e.wasClean]);
|
||||
state.resolve();
|
||||
});
|
||||
|
||||
ws.close(3000, 'changed my mind');
|
||||
received.push(ws.readyState); // CLOSED = 3, immediately
|
||||
|
||||
await state.done(() => {
|
||||
// open never fires; the close event is delivered asynchronously with
|
||||
// the values close() was given and wasClean=false.
|
||||
testing.expectEqual([3, ['close', 3000, 'changed my mind', false]], received);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=ws_within_callback type=module>
|
||||
{
|
||||
const state = await testing.async();
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
let c4 = document.createElement('div');
|
||||
c4.id = 'c4';
|
||||
|
||||
// Per spec, a child with the wrong parent is a NotFoundError.
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(3, err.code);
|
||||
testing.expectEqual("HierarchyRequestError", err.name);
|
||||
testing.expectEqual(8, err.code);
|
||||
testing.expectEqual("NotFoundError", err.name);
|
||||
}, () => d1.replaceChild(c4, c3));
|
||||
|
||||
testing.expectEqual(c2, d1.replaceChild(c4, c2));
|
||||
|
||||
8
src/browser/tests/shared_worker/counter-worker.js
Normal file
8
src/browser/tests/shared_worker/counter-worker.js
Normal file
@@ -0,0 +1,8 @@
|
||||
let counter = 0;
|
||||
onconnect = (e) => {
|
||||
const port = e.ports[0];
|
||||
port.onmessage = () => {
|
||||
counter++;
|
||||
port.postMessage({ count: counter, name: self.name });
|
||||
};
|
||||
};
|
||||
8
src/browser/tests/shared_worker/echo-worker.js
Normal file
8
src/browser/tests/shared_worker/echo-worker.js
Normal file
@@ -0,0 +1,8 @@
|
||||
onconnect = (e) => {
|
||||
// Per spec the connect event's source is the port itself (testharness.js
|
||||
// relies on this rather than ports[0]).
|
||||
const port = e.source;
|
||||
port.onmessage = (event) => {
|
||||
port.postMessage({ echo: event.data, from: 'shared-worker', sourceIsPort: e.source === e.ports[0] });
|
||||
};
|
||||
};
|
||||
10
src/browser/tests/shared_worker/listener-worker.js
Normal file
10
src/browser/tests/shared_worker/listener-worker.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Exercises the addEventListener flavor on both the global ('connect') and
|
||||
// the port ('message' + explicit start()), instead of the auto-starting
|
||||
// onconnect/onmessage attribute setters.
|
||||
self.addEventListener('connect', (e) => {
|
||||
const port = e.ports[0];
|
||||
port.addEventListener('message', (event) => {
|
||||
port.postMessage('listener:' + event.data);
|
||||
});
|
||||
port.start();
|
||||
});
|
||||
137
src/browser/tests/shared_worker/shared_worker.html
Normal file
137
src/browser/tests/shared_worker/shared_worker.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<body></body>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id="shared_worker_creation">
|
||||
{
|
||||
const worker = new SharedWorker('./echo-worker.js');
|
||||
testing.expectTrue(worker instanceof SharedWorker);
|
||||
testing.expectTrue(worker.port instanceof MessagePort);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="shared_worker_echo" type=module>
|
||||
// Also exercises post-before-load: the message is posted while the worker
|
||||
// script's fetch is still in flight, so it must sit in the worker-side
|
||||
// port's queue until the connect handler assigns port.onmessage.
|
||||
{
|
||||
const state = await testing.async();
|
||||
const worker = new SharedWorker('./echo-worker.js');
|
||||
|
||||
worker.port.onmessage = (event) => {
|
||||
state.resolve(event.data);
|
||||
};
|
||||
worker.port.postMessage({ greeting: 'hello' });
|
||||
|
||||
await state.done((response) => {
|
||||
testing.expectEqual('hello', response.echo.greeting);
|
||||
testing.expectEqual('shared-worker', response.from);
|
||||
testing.expectEqual(true, response.sourceIsPort);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="shared_worker_shared_instance" type=module>
|
||||
// Two SharedWorker handles for the same (url, name) are distinct JS
|
||||
// objects but connect to the same worker: the counter is shared.
|
||||
{
|
||||
const state = await testing.async();
|
||||
const w1 = new SharedWorker('./counter-worker.js');
|
||||
const w2 = new SharedWorker('./counter-worker.js');
|
||||
testing.expectTrue(w1 !== w2);
|
||||
testing.expectTrue(w1.port !== w2.port);
|
||||
|
||||
const incr = (w) => new Promise((resolve) => {
|
||||
w.port.onmessage = (e) => resolve(e.data);
|
||||
w.port.postMessage('incr');
|
||||
});
|
||||
|
||||
const c1 = await incr(w1);
|
||||
const c2 = await incr(w2);
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(1, c1.count);
|
||||
testing.expectEqual(2, c2.count);
|
||||
testing.expectEqual('', c1.name);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="shared_worker_named_isolation" type=module>
|
||||
// Different names on the same url are different workers, and each worker
|
||||
// sees its own name. The second argument can be a string or an options
|
||||
// object.
|
||||
{
|
||||
const state = await testing.async();
|
||||
const wa = new SharedWorker('./counter-worker.js', 'a');
|
||||
const wb = new SharedWorker('./counter-worker.js', { name: 'b' });
|
||||
|
||||
const incr = (w) => new Promise((resolve) => {
|
||||
w.port.onmessage = (e) => resolve(e.data);
|
||||
w.port.postMessage('incr');
|
||||
});
|
||||
|
||||
const ca = await incr(wa);
|
||||
const cb = await incr(wb);
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(1, ca.count);
|
||||
testing.expectEqual('a', ca.name);
|
||||
testing.expectEqual(1, cb.count);
|
||||
testing.expectEqual('b', cb.name);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="shared_worker_add_event_listener" type=module>
|
||||
// addEventListener('message') on the page-side port does not auto-start
|
||||
// it; an explicit start() is required. The worker script likewise uses
|
||||
// addEventListener('connect') + port.start().
|
||||
{
|
||||
const state = await testing.async();
|
||||
const worker = new SharedWorker('./listener-worker.js');
|
||||
|
||||
worker.port.addEventListener('message', (event) => {
|
||||
state.resolve(event.data);
|
||||
});
|
||||
worker.port.start();
|
||||
worker.port.postMessage('ping');
|
||||
|
||||
await state.done((response) => {
|
||||
testing.expectEqual('listener:ping', response);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="shared_worker_structured_clone" type=module>
|
||||
// Messages cross a context boundary: the receiver gets an independent
|
||||
// structured clone, and unserializable values throw a DataCloneError.
|
||||
{
|
||||
const state = await testing.async();
|
||||
const worker = new SharedWorker('./echo-worker.js');
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
worker.port.postMessage(function () {});
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
testing.expectEqual('DataCloneError', e.name);
|
||||
}
|
||||
testing.expectEqual(true, threw);
|
||||
|
||||
const payload = { date: new Date('2024-06-15T12:30:00Z'), arr: [1, 2, 3] };
|
||||
worker.port.onmessage = (event) => {
|
||||
state.resolve(event.data);
|
||||
};
|
||||
worker.port.postMessage(payload);
|
||||
payload.arr.push(4); // must not be visible to the worker
|
||||
|
||||
await state.done((response) => {
|
||||
testing.expectTrue(response.echo.date instanceof Date);
|
||||
testing.expectEqual(payload.date.getTime(), response.echo.date.getTime());
|
||||
testing.expectEqual(3, response.echo.arr.length);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -78,7 +78,7 @@
|
||||
async function async() {
|
||||
const script_id = (IS_TEST_RUNNER) ? document.currentScript.id : 'cannot track module id in FF/Chrome';
|
||||
|
||||
if (async_seen.has(script_id)) {
|
||||
if (async_seen.has(script_id) && IS_TEST_RUNNER) {
|
||||
throw new Error(`testing.async() called more than once for script '${script_id}'. A script may only register one async block (the runner can declare success in the gap between two of them); split the test into separate <script> tags.`);
|
||||
}
|
||||
async_seen.add(script_id);
|
||||
|
||||
@@ -68,3 +68,24 @@
|
||||
location.hash = "";
|
||||
testing.expectEqual("", location.hash);
|
||||
</script>
|
||||
|
||||
<script id=location_hash_no_change>
|
||||
// Per the Location hash setter, assigning a value that doesn't change the
|
||||
// fragment must not navigate at all: re-assigning "" on a fragment-less URL
|
||||
// must not schedule a same-URL reload, and re-assigning the current
|
||||
// fragment must be a no-op too.
|
||||
const stable_href = location.href;
|
||||
location.hash = "";
|
||||
location.hash = "";
|
||||
testing.expectEqual(stable_href, location.href);
|
||||
|
||||
location.hash = "#same";
|
||||
const with_fragment = location.href;
|
||||
location.hash = "#same";
|
||||
location.hash = "same";
|
||||
testing.expectEqual(with_fragment, location.href);
|
||||
testing.expectEqual("#same", location.hash);
|
||||
|
||||
location.hash = "";
|
||||
testing.expectEqual(stable_href, location.href);
|
||||
</script>
|
||||
|
||||
@@ -34,3 +34,52 @@
|
||||
testing.expectEqual(0, window.scrollX);
|
||||
testing.expectEqual(0, window.scrollY);
|
||||
</script>
|
||||
|
||||
<script id=element_scroll_events_coalesce_and_preserve_reentrancy type=module>
|
||||
const state = await testing.async();
|
||||
const element = document.createElement('div');
|
||||
let scrolls = 0;
|
||||
let scrollends = 0;
|
||||
|
||||
element.addEventListener('scroll', () => {
|
||||
scrolls++;
|
||||
if (scrolls === 1) {
|
||||
element.scrollTop = 101;
|
||||
}
|
||||
});
|
||||
element.addEventListener('scrollend', () => {
|
||||
scrollends++;
|
||||
if (scrollends === 1) {
|
||||
element.scrollTop = 102;
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
element.scrollTop = i;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(3, scrolls);
|
||||
testing.expectEqual(2, scrollends);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=element_scrollBy_noop_does_not_fire_events type=module>
|
||||
const state = await testing.async();
|
||||
const element = document.createElement('div');
|
||||
let scrolls = 0;
|
||||
let scrollends = 0;
|
||||
|
||||
element.addEventListener('scroll', () => scrolls++);
|
||||
element.addEventListener('scrollend', () => scrollends++);
|
||||
element.scrollBy(-1, -1);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(0, scrolls);
|
||||
testing.expectEqual(0, scrollends);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -483,3 +483,26 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="worker_from_blob_url_revoked_before_delivery" type=module>
|
||||
// Revoking right after construction must not invalidate the queued
|
||||
// script response: it's delivered on a later tick, after the blob's
|
||||
// last reference may be gone.
|
||||
{
|
||||
const state = await testing.async();
|
||||
const blob = new Blob(["onmessage = (e) => postMessage({ echo: e.data, from: 'revoked-blob-worker' });"], { type: 'text/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const worker = new Worker(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
|
||||
worker.onmessage = function(event) {
|
||||
state.resolve(event.data);
|
||||
};
|
||||
worker.postMessage({ greeting: 'still-works' });
|
||||
|
||||
await state.done((response) => {
|
||||
testing.expectEqual('still-works', response.echo.greeting);
|
||||
testing.expectEqual('revoked-blob-worker', response.from);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
|
||||
return self._signal;
|
||||
}
|
||||
|
||||
pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void {
|
||||
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
|
||||
pub fn abort(self: *AbortController, reason_: ?js.Value, exec: *const Execution) !void {
|
||||
try self._signal.abort(try AbortSignal.reasonFromJs(reason_), exec);
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
|
||||
@@ -35,14 +35,18 @@ const Dependend = union(enum) {
|
||||
signal: *AbortSignal,
|
||||
model_context_tool: *ModelContextTool,
|
||||
|
||||
fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !void {
|
||||
// Returns false if the dependent was already aborted, in which case no
|
||||
// abort event must be dispatched for it.
|
||||
fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !bool {
|
||||
switch (self) {
|
||||
.signal => |dep| {
|
||||
if (dep._aborted) return;
|
||||
if (dep._aborted) return false;
|
||||
try dep.markAborted(reason_, exec);
|
||||
return true;
|
||||
},
|
||||
.model_context_tool => |dep| {
|
||||
try dep.markAborted(exec);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -101,8 +105,9 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void
|
||||
// so we never need to recurse here.
|
||||
var to_dispatch: std.ArrayList(Dependend) = .{};
|
||||
for (self._dependents.items) |dep| {
|
||||
try dep.markAborted(self._reason, exec);
|
||||
try to_dispatch.append(exec.arena, dep);
|
||||
if (try dep.markAborted(self._reason, exec)) {
|
||||
try to_dispatch.append(exec.call_arena, dep);
|
||||
}
|
||||
}
|
||||
|
||||
try self.dispatchAbortEvent(exec);
|
||||
@@ -123,7 +128,11 @@ fn markAborted(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !vo
|
||||
.undefined => self._reason = reason,
|
||||
}
|
||||
} else {
|
||||
self._reason = .{ .dom = DOMException.fromError(error.AbortError).? };
|
||||
// Allocate the DOMException so the reason keeps a single JS identity:
|
||||
// dependent signals must expose the very same DOMException instance.
|
||||
const dom = try exec.arena.create(DOMException);
|
||||
dom.* = DOMException.fromError(error.AbortError).?;
|
||||
self._reason = .{ .dom = dom };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,14 +149,30 @@ fn dispatchAbortEvent(self: *AbortSignal, exec: *const Execution) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an abort(reason) JS argument to a Reason. Per spec, only a
|
||||
// missing or undefined reason falls back to the default "AbortError"
|
||||
// DOMException: an explicit null (or any other value) is kept as-is.
|
||||
pub fn reasonFromJs(reason_: ?js.Value) !?Reason {
|
||||
const reason = reason_ orelse return null;
|
||||
if (reason.isUndefined()) {
|
||||
return null;
|
||||
}
|
||||
return .{ .js_val = try reason.persist() };
|
||||
}
|
||||
|
||||
// Static method to create an already-aborted signal
|
||||
pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal {
|
||||
pub fn createAborted(reason_: ?js.Value, exec: *const Execution) !*AbortSignal {
|
||||
const signal = try init(exec);
|
||||
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
|
||||
try signal.abort(try reasonFromJs(reason_), exec);
|
||||
return signal;
|
||||
}
|
||||
|
||||
pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortSignal {
|
||||
pub fn createAny(signals_value: js.Value, exec: *const Execution) !*AbortSignal {
|
||||
// The parameter isn't optional. If we declared it as a slice directy, the
|
||||
// bridge would treat it as a variadic and map it to empty rather than throwing
|
||||
// a TypeError as it should.
|
||||
const signals = try signals_value.toZig([]const *AbortSignal);
|
||||
|
||||
const result = try init(exec);
|
||||
for (signals) |source| {
|
||||
if (source._aborted) {
|
||||
@@ -207,7 +232,7 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf
|
||||
|
||||
const Reason = union(enum) {
|
||||
js_val: js.Value.Global,
|
||||
dom: DOMException,
|
||||
dom: *DOMException,
|
||||
string: []const u8,
|
||||
undefined: void,
|
||||
};
|
||||
@@ -218,11 +243,33 @@ const TimeoutCallback = struct {
|
||||
|
||||
fn run(ctx: *anyopaque) !?u32 {
|
||||
const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
|
||||
self.signal.abort(.{ .dom = DOMException.fromError(error.TimeoutError).? }, self.exec) catch |err| {
|
||||
self.timeoutAbort() catch |err| {
|
||||
log.warn(.app, "abort signal timeout", .{ .err = err });
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
fn timeoutAbort(self: *TimeoutCallback) !void {
|
||||
// Per spec, the abort is queued as a global task on the signal's
|
||||
// global: it must not run when the global's document is no longer
|
||||
// fully active (e.g. the iframe that created the signal was detached).
|
||||
switch (self.exec.js.global) {
|
||||
.frame => |frame| {
|
||||
var current: ?@TypeOf(frame) = frame;
|
||||
while (current) |f| : (current = f.parent) {
|
||||
const iframe = f.iframe orelse continue;
|
||||
if (!iframe.asNode().isConnected()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
.worker => {},
|
||||
}
|
||||
|
||||
const dom = try self.exec.arena.create(DOMException);
|
||||
dom.* = DOMException.fromError(error.TimeoutError).?;
|
||||
try self.signal.abort(.{ .dom = dom }, self.exec);
|
||||
}
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
@@ -237,7 +284,6 @@ pub const JsApi = struct {
|
||||
|
||||
pub const Prototype = EventTarget;
|
||||
|
||||
pub const constructor = bridge.constructor(AbortSignal.init, .{});
|
||||
pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{});
|
||||
pub const reason = bridge.accessor(AbortSignal.getReason, null, .{});
|
||||
pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{});
|
||||
|
||||
@@ -88,7 +88,7 @@ pub fn postMessage(self: *BroadcastChannel, message: js.Value, exec: *Execution)
|
||||
const cloned = message.structuredCloneTo(&ls.local) catch {
|
||||
return error.DataClone;
|
||||
};
|
||||
break :blk try cloned.temp();
|
||||
break :blk try cloned.persist();
|
||||
};
|
||||
errdefer snapshot.release();
|
||||
|
||||
@@ -134,7 +134,7 @@ const PostMessageCallback = struct {
|
||||
sender: *BroadcastChannel,
|
||||
// A self-owned structured-clone snapshot of the posted message. Re-cloned
|
||||
// (never shared) into each receiver's MessageEvent, then released.
|
||||
message: js.Value.Temp,
|
||||
message: js.Value.Global,
|
||||
exec: *Execution,
|
||||
post_sequence: u64,
|
||||
|
||||
@@ -221,7 +221,7 @@ const PostMessageCallback = struct {
|
||||
continue;
|
||||
};
|
||||
|
||||
const cloned_temp = cloned.temp() catch |err| {
|
||||
const cloned_temp = cloned.persist() catch |err| {
|
||||
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -40,6 +40,13 @@ _data: String = .empty,
|
||||
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
|
||||
/// everything else produces 1.
|
||||
pub fn utf16Len(data: []const u8) usize {
|
||||
// All-ASCII data (the common case) is one code unit per byte.
|
||||
for (data) |byte| {
|
||||
if (byte >= 0x80) {
|
||||
break;
|
||||
}
|
||||
} else return data.len;
|
||||
|
||||
var count: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i < data.len) {
|
||||
@@ -96,6 +103,41 @@ pub fn utf16OffsetToUtf8(data: []const u8, utf16_offset: usize) error{IndexSizeE
|
||||
return error.IndexSizeError;
|
||||
}
|
||||
|
||||
/// Convert a UTF-16 code unit offset to a UTF-8 byte offset, rounding an
|
||||
/// offset that lands inside a surrogate pair down to the code point's start
|
||||
/// and clamping an offset past the end to data.len. Unlike the exact-or-error
|
||||
/// variant above, this is total and monotonic, so converting a start/end pair
|
||||
/// always yields ordered byte offsets.
|
||||
pub fn utf16OffsetToUtf8Floor(data: []const u8, utf16_offset: usize) usize {
|
||||
var utf16_pos: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i < data.len) {
|
||||
if (utf16_pos == utf16_offset) return i;
|
||||
const byte = data[i];
|
||||
const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch {
|
||||
i += 1;
|
||||
utf16_pos += 1;
|
||||
continue;
|
||||
};
|
||||
if (i + seq_len > data.len) {
|
||||
utf16_pos += 1;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (seq_len == 4) {
|
||||
if (utf16_offset == utf16_pos + 1) {
|
||||
// Between the two code units of a surrogate pair.
|
||||
return i;
|
||||
}
|
||||
utf16_pos += 2;
|
||||
} else {
|
||||
utf16_pos += 1;
|
||||
}
|
||||
i += seq_len;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/// Convert a UTF-16 code unit range to UTF-8 byte offsets in a single pass.
|
||||
/// Returns IndexSizeError if utf16_start > utf16 length of data.
|
||||
/// Clamps utf16_end to the actual string length if it exceeds it.
|
||||
@@ -527,6 +569,31 @@ test "utf16OffsetToUtf8" {
|
||||
try std.testing.expectError(error.IndexSizeError, utf16OffsetToUtf8("", 1));
|
||||
}
|
||||
|
||||
test "utf16OffsetToUtf8Floor" {
|
||||
// ASCII: identical to the exact variant, clamps past the end
|
||||
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("hello", 0));
|
||||
try std.testing.expectEqual(@as(usize, 3), utf16OffsetToUtf8Floor("hello", 3));
|
||||
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("hello", 5));
|
||||
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("hello", 6)); // clamped
|
||||
|
||||
// Emoji "🌠AB" (4+1+1 = 6 bytes; 2+1+1 = 4 UTF-16 code units).
|
||||
// Offset 1 lands inside the surrogate pair and rounds down to its start.
|
||||
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("🌠AB", 0));
|
||||
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("🌠AB", 1)); // mid-surrogate
|
||||
try std.testing.expectEqual(@as(usize, 4), utf16OffsetToUtf8Floor("🌠AB", 2));
|
||||
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("🌠AB", 3));
|
||||
try std.testing.expectEqual(@as(usize, 6), utf16OffsetToUtf8Floor("🌠AB", 4));
|
||||
try std.testing.expectEqual(@as(usize, 6), utf16OffsetToUtf8Floor("🌠AB", 5)); // clamped
|
||||
|
||||
// Trailing surrogate pair: mid-surrogate at the very end still floors
|
||||
try std.testing.expectEqual(@as(usize, 1), utf16OffsetToUtf8Floor("A🌠", 2)); // mid-surrogate
|
||||
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("A🌠", 3));
|
||||
|
||||
// Empty string
|
||||
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("", 0));
|
||||
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("", 1)); // clamped
|
||||
}
|
||||
|
||||
test "utf16RangeToUtf8" {
|
||||
// ASCII: basic range
|
||||
{
|
||||
|
||||
@@ -29,7 +29,30 @@ pub fn parseDimension(value: []const u8) ?f64 {
|
||||
if (value.len == 0) {
|
||||
return null;
|
||||
}
|
||||
return parseNonEmptyDimension(value);
|
||||
}
|
||||
|
||||
// parseDimension plus viewport-relative units, which the faux layout
|
||||
// resolves against the page viewport.
|
||||
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
|
||||
if (value.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, value, "vh")) {
|
||||
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
|
||||
return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0;
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, value, "vw")) {
|
||||
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
|
||||
return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0;
|
||||
}
|
||||
|
||||
return parseNonEmptyDimension(value);
|
||||
}
|
||||
|
||||
fn parseNonEmptyDimension(value: []const u8) ?f64 {
|
||||
var num_str = value;
|
||||
if (std.mem.endsWith(u8, value, "px")) {
|
||||
num_str = value[0 .. value.len - 2];
|
||||
|
||||
@@ -16,16 +16,22 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const crypto = @import("../../sys/libcrypto.zig");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
|
||||
const Execution = js.Execution;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Represents a cryptographic key obtained from one of the SubtleCrypto methods
|
||||
/// generateKey(), deriveKey(), importKey(), or unwrapKey().
|
||||
const CryptoKey = @This();
|
||||
|
||||
_rc: lp.RC(u8) = .{},
|
||||
_arena: Allocator = undefined,
|
||||
/// Algorithm being used.
|
||||
_type: Type,
|
||||
/// Whether this is a secret (symmetric), public, or private key. Surfaced as
|
||||
@@ -37,8 +43,8 @@ _extractable: bool,
|
||||
_usages: u8,
|
||||
/// Raw bytes of key.
|
||||
_key: []const u8,
|
||||
/// Metadata needed to reconstruct the JS `.algorithm` dictionary. The strings
|
||||
/// are expected to outlive the key (arena-allocated alongside it).
|
||||
/// Metadata needed to reconstruct the JS `.algorithm` dictionary. `hash` is
|
||||
/// duped into the key's arena by init; `name`/`named_curve` are static.
|
||||
_algorithm: Algorithm,
|
||||
/// Different algorithms may use different data structures;
|
||||
/// this union can be used for such situations. Active field is understood
|
||||
@@ -94,6 +100,39 @@ pub const Usages = struct {
|
||||
// zig fmt: on
|
||||
};
|
||||
|
||||
/// Copies `key` into an owned pooled arena, duping the caller-provided
|
||||
/// slices (`_key`, `_algorithm.hash`). `_algorithm.name` and `_named_curve`
|
||||
/// are expected to be static strings. Takes ownership of `_vary.pkey`.
|
||||
pub fn init(exec: *const Execution, key: CryptoKey) !*CryptoKey {
|
||||
const arena = try exec.getArena(.tiny, "CryptoKey");
|
||||
errdefer exec.releaseArena(arena);
|
||||
|
||||
const self = try arena.create(CryptoKey);
|
||||
self.* = key;
|
||||
self._arena = arena;
|
||||
self._key = try arena.dupe(u8, key._key);
|
||||
if (key._algorithm.hash) |hash| {
|
||||
self._algorithm.hash = try arena.dupe(u8, hash);
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CryptoKey, page: *Page) void {
|
||||
switch (self._vary) {
|
||||
.pkey => |pkey| crypto.EVP_PKEY_free(pkey),
|
||||
.none, .digest => {},
|
||||
}
|
||||
page.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *CryptoKey, page: *Page) void {
|
||||
self._rc.release(self, page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *CryptoKey) void {
|
||||
self._rc.acquire();
|
||||
}
|
||||
|
||||
pub fn canEncrypt(self: *const CryptoKey) bool {
|
||||
return self._usages & Usages.encrypt != 0;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("../js/js.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
const Node = @import("Node.zig");
|
||||
@@ -23,16 +24,33 @@ const Document = @import("Document.zig");
|
||||
const DocumentType = @import("DocumentType.zig");
|
||||
|
||||
const DOMImplementation = @This();
|
||||
_pad: bool = false,
|
||||
|
||||
pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType {
|
||||
return DocumentType.init(qualified_name, public_id, system_id, frame);
|
||||
// The document this implementation object belongs to: nodes created through
|
||||
// it are owned by that document, not necessarily the frame's main document.
|
||||
_document: *Document,
|
||||
|
||||
pub fn createDocumentType(self: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType {
|
||||
// Per spec, qualifiedName must match the doctype name production: any
|
||||
// characters except ASCII whitespace or '>'.
|
||||
for (qualified_name) |c| {
|
||||
switch (c) {
|
||||
'\t', '\n', 0x0C, '\r', ' ', '>' => return error.InvalidCharacterError,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const doctype = try DocumentType.init(qualified_name, public_id, system_id, frame);
|
||||
if (self._document != frame.document) {
|
||||
try frame.setNodeOwnerDocument(doctype.asNode(), self._document);
|
||||
}
|
||||
return doctype;
|
||||
}
|
||||
|
||||
pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString, frame: *Frame) !*Document {
|
||||
const document = (try frame._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument();
|
||||
document._ready_state = .complete;
|
||||
document._url = "about:blank";
|
||||
document._charset = "UTF-8";
|
||||
|
||||
{
|
||||
const doctype = try frame._factory.node(DocumentType{
|
||||
@@ -63,10 +81,35 @@ pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString
|
||||
return document;
|
||||
}
|
||||
|
||||
pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, frame: *Frame) !*Document {
|
||||
pub fn createDocument(_: *const DOMImplementation, namespace_nullable: js.Nullable([]const u8), qualified_name_: js.Value, doctype: ?*DocumentType, frame: *Frame) !*Document {
|
||||
// Both namespace (nullable) and qualifiedName are required arguments.
|
||||
const namespace_ = namespace_nullable.value;
|
||||
|
||||
// Per Web IDL, qualifiedName is [LegacyNullToEmptyString]: null becomes
|
||||
// the empty string, while undefined stringifies to "undefined". The raw
|
||||
// js.Value keeps that distinction.
|
||||
const qname: []const u8 = blk: {
|
||||
if (qualified_name_.isNull()) {
|
||||
break :blk "";
|
||||
}
|
||||
break :blk try qualified_name_.toStringSlice();
|
||||
};
|
||||
|
||||
if (qname.len > 0) {
|
||||
_ = try Document.validateAndExtract(namespace_, qname, .element);
|
||||
}
|
||||
|
||||
// Create XML Document
|
||||
const document = (try frame._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument();
|
||||
document._url = "about:blank";
|
||||
document._charset = "UTF-8";
|
||||
// Per spec the content type depends on the requested namespace.
|
||||
document._content_type = blk: {
|
||||
const ns = namespace_ orelse break :blk "application/xml";
|
||||
if (std.mem.eql(u8, ns, "http://www.w3.org/1999/xhtml")) break :blk "application/xhtml+xml";
|
||||
if (std.mem.eql(u8, ns, "http://www.w3.org/2000/svg")) break :blk "image/svg+xml";
|
||||
break :blk "application/xml";
|
||||
};
|
||||
|
||||
// Append doctype if provided
|
||||
if (doctype) |dt| {
|
||||
@@ -74,12 +117,20 @@ pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qual
|
||||
}
|
||||
|
||||
// Create and append root element if qualified_name provided
|
||||
if (qualified_name) |qname| {
|
||||
if (qname.len > 0) {
|
||||
const namespace = Node.Element.Namespace.parse(namespace_);
|
||||
const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null);
|
||||
_ = try document.asNode().appendChild(root, frame);
|
||||
if (qname.len > 0) {
|
||||
const namespace = Node.Element.Namespace.parse(namespace_);
|
||||
const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null);
|
||||
|
||||
// Store the original URI for unknown namespaces so namespaceURI and
|
||||
// lookupNamespaceURI can return it (mirrors Document.createElementNS).
|
||||
if (namespace == .unknown) {
|
||||
if (namespace_) |uri| {
|
||||
const duped = try frame.dupeString(uri);
|
||||
try frame._element_namespace_uris.put(frame.arena, root.as(Node.Element), duped);
|
||||
}
|
||||
}
|
||||
|
||||
_ = try document.asNode().appendChild(root, frame);
|
||||
}
|
||||
|
||||
return document;
|
||||
@@ -98,7 +149,6 @@ pub const JsApi = struct {
|
||||
pub const name = "DOMImplementation";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const empty_with_no_proto = true;
|
||||
};
|
||||
|
||||
pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{});
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Frame = @import("../Frame.zig");
|
||||
|
||||
const Node = @import("Node.zig");
|
||||
@@ -25,28 +29,87 @@ pub const FilterOpts = NodeFilter.FilterOpts;
|
||||
|
||||
const DOMNodeIterator = @This();
|
||||
|
||||
_rc: lp.RC(u8) = .{},
|
||||
_root: *Node,
|
||||
_what_to_show: u32,
|
||||
_filter: NodeFilter,
|
||||
_reference_node: *Node,
|
||||
_pointer_before_reference_node: bool,
|
||||
_active: bool = false,
|
||||
_frame_loader_id: u32,
|
||||
_iterator_link: std.DoublyLinkedList.Node = .{},
|
||||
|
||||
pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, frame: *Frame) !*DOMNodeIterator {
|
||||
const node_filter = try NodeFilter.init(filter);
|
||||
return frame._factory.create(DOMNodeIterator{
|
||||
const iterator = try frame._factory.create(DOMNodeIterator{
|
||||
._root = root,
|
||||
._filter = node_filter,
|
||||
._reference_node = root,
|
||||
._what_to_show = what_to_show,
|
||||
._frame_loader_id = frame._loader_id,
|
||||
._pointer_before_reference_node = true,
|
||||
});
|
||||
frame._live_node_iterators.append(&iterator._iterator_link);
|
||||
return iterator;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DOMNodeIterator, page: *Page) void {
|
||||
if (page.findFrameByLoaderId(self._frame_loader_id)) |frame| {
|
||||
frame._live_node_iterators.remove(&self._iterator_link);
|
||||
}
|
||||
self._filter.deinit();
|
||||
page.factory.destroy(self);
|
||||
}
|
||||
|
||||
pub fn releaseRef(self: *DOMNodeIterator, page: *Page) void {
|
||||
self._rc.release(self, page);
|
||||
}
|
||||
|
||||
pub fn acquireRef(self: *DOMNodeIterator) void {
|
||||
self._rc.acquire();
|
||||
}
|
||||
|
||||
pub fn getRoot(self: *const DOMNodeIterator) *Node {
|
||||
return self._root;
|
||||
}
|
||||
|
||||
// DOM "node iterator pre-removing steps", run while the tree still contains
|
||||
// to_be_removed.
|
||||
pub fn nodeWillBeRemoved(self: *DOMNodeIterator, to_be_removed: *Node) void {
|
||||
if (to_be_removed.contains(self._root)) {
|
||||
// Removing the root or one of its ancestors leaves the iterator alone.
|
||||
return;
|
||||
}
|
||||
if (to_be_removed != self._reference_node and to_be_removed.contains(self._reference_node) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self._pointer_before_reference_node) {
|
||||
// The first node following to_be_removed's subtree, if any.
|
||||
var node = to_be_removed;
|
||||
while (node != self._root) {
|
||||
if (node.nextSibling()) |sibling| {
|
||||
self._reference_node = sibling;
|
||||
return;
|
||||
}
|
||||
node = node.parentNode() orelse break;
|
||||
}
|
||||
self._pointer_before_reference_node = false;
|
||||
}
|
||||
|
||||
// The node immediately preceding to_be_removed in tree order: the
|
||||
// previous sibling's last inclusive descendant, or the parent.
|
||||
if (to_be_removed.previousSibling()) |prev| {
|
||||
var node = prev;
|
||||
while (node.lastChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
self._reference_node = node;
|
||||
} else {
|
||||
self._reference_node = to_be_removed.parentNode() orelse self._root;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getReferenceNode(self: *const DOMNodeIterator) *Node {
|
||||
return self._reference_node;
|
||||
}
|
||||
@@ -60,7 +123,7 @@ pub fn getWhatToShow(self: *const DOMNodeIterator) u32 {
|
||||
}
|
||||
|
||||
pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
|
||||
return self._filter._original_filter;
|
||||
return self._filter._opts;
|
||||
}
|
||||
|
||||
pub fn nextNode(self: *DOMNodeIterator, frame: *Frame) !?*Node {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user