Merge branch 'main' into semantic-versioning

This commit is contained in:
Adrià Arrufat
2026-03-20 07:04:05 +09:00
17 changed files with 772 additions and 366 deletions

View File

@@ -192,13 +192,15 @@ jobs:
- run: chmod a+x ./lightpanda - run: chmod a+x ./lightpanda
# force a wakup of the auth server before requesting it w/ the test itself
- run: curl https://${{ vars.WBA_DOMAIN }}
- name: run wba test - name: run wba test
shell: bash shell: bash
run: | run: |
node webbotauth/validator.js & node webbotauth/validator.js &
VALIDATOR_PID=$! VALIDATOR_PID=$!
sleep 2 sleep 5
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"

View File

@@ -10,7 +10,7 @@ env:
on: on:
schedule: schedule:
- cron: "23 2 * * *" - cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
@@ -19,8 +19,12 @@ jobs:
wpt-build-release: wpt-build-release:
name: zig build release name: zig build release
runs-on: ubuntu-latest env:
timeout-minutes: 15 ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -28,9 +32,15 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- uses: ./.github/actions/install - uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build release - name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -43,7 +53,7 @@ jobs:
wpt-build-runner: wpt-build-runner:
name: build wpt runner name: build wpt runner
runs-on: ubuntu-latest runs-on: ubuntu-24.04-arm
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@@ -71,8 +81,8 @@ jobs:
- wpt-build-runner - wpt-build-runner
# use a self host runner. # use a self host runner.
runs-on: lpd-bench-hetzner runs-on: lpd-wpt-aws
timeout-minutes: 180 timeout-minutes: 600
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -105,8 +115,8 @@ jobs:
- name: run test with json output - name: run test with json output
run: | run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid ./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s sleep 20s
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid` kill `cat WPT.pid`
- name: write commit - name: write commit

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands # $(ZIG) commands
# ------------ # ------------
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end .PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
## Build v8 snapshot ## Build v8 snapshot
build-v8-snapshot: build-v8-snapshot:
@@ -77,11 +77,6 @@ run-debug: build-dev
@printf "\033[36mRunning...\033[0m\n" @printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;) @./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
## Run a JS shell in debug mode
shell:
@printf "\033[36mBuilding shell...\033[0m\n"
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Test - `grep` is used to filter out the huge compile command on build ## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos) ifeq ($(OS), macos)
test: test:
@@ -106,4 +101,3 @@ install: build
data: data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig

View File

@@ -38,6 +38,7 @@ page: *Page,
arena: std.mem.Allocator, arena: std.mem.Allocator,
prune: bool = true, prune: bool = true,
interactive_only: bool = false, interactive_only: bool = false,
max_depth: u32 = std.math.maxInt(u32) - 1,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
var visitor = JsonVisitor{ .jw = jw, .tree = self }; var visitor = JsonVisitor{ .jw = jw, .tree = self };
@@ -46,7 +47,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
log.err(.app, "listener map failed", .{ .err = err }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err }); log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -59,7 +60,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
log.err(.app, "listener map failed", .{ .err = err }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| { self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err }); log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -72,7 +73,7 @@ const OptionData = struct {
}; };
const NodeData = struct { const NodeData = struct {
id: u32, id: CDPNode.Id,
axn: AXNode, axn: AXNode,
role: []const u8, role: []const u8,
name: ?[]const u8, name: ?[]const u8,
@@ -83,7 +84,9 @@ const NodeData = struct {
node_name: []const u8, node_name: []const u8,
}; };
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void { fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void {
if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes // 1. Skip non-content nodes
if (node.is(Element)) |el| { if (node.is(Element)) |el| {
const tag = el.getTag(); const tag = el.getTag();
@@ -230,7 +233,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
} }
gop.value_ptr.* += 1; gop.value_ptr.* += 1;
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets); try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
} }
} }
@@ -474,3 +477,56 @@ const TextVisitor = struct {
} }
} }
}; };
const testing = @import("testing.zig");
test "SemanticTree backendDOMNodeId" {
var registry: CDPNode.Registry = .init(testing.allocator);
defer registry.deinit();
var page = try testing.pageTest("cdp/registry1.html");
defer testing.reset();
defer page._session.removePage();
const st: Self = .{
.dom_node = page.window._document.asNode(),
.registry = &registry,
.page = page,
.arena = testing.arena_allocator,
.prune = false,
.interactive_only = false,
.max_depth = std.math.maxInt(u32) - 1,
};
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
defer testing.allocator.free(json_str);
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
}
test "SemanticTree max_depth" {
var registry: CDPNode.Registry = .init(testing.allocator);
defer registry.deinit();
var page = try testing.pageTest("cdp/registry1.html");
defer testing.reset();
defer page._session.removePage();
const st: Self = .{
.dom_node = page.window._document.asNode(),
.registry = &registry,
.page = page,
.arena = testing.arena_allocator,
.prune = false,
.interactive_only = false,
.max_depth = 1,
};
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try st.textStringify(&aw.writer);
const text_str = aw.written();
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
}

View File

@@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
return resolver.promise(); return resolver.promise();
} }
pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.rejectError("Local.rejectPromise", value);
return resolver.promise();
}
pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise { pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise {
var resolver = js.PromiseResolver.init(self); var resolver = js.PromiseResolver.init(self);
resolver.resolve("Local.resolvePromise", value); resolver.resolve("Local.resolvePromise", value);

View File

@@ -18,7 +18,9 @@
const js = @import("js.zig"); const js = @import("js.zig");
const v8 = js.v8; const v8 = js.v8;
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const DOMException = @import("../webapi/DOMException.zig");
const PromiseResolver = @This(); const PromiseResolver = @This();
@@ -63,14 +65,19 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
}; };
} }
const RejectError = union(enum) { pub const RejectError = union(enum) {
generic: []const u8, generic: []const u8,
type_error: []const u8, type_error: []const u8,
dom_exception: anyerror,
}; };
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void { pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
const handle = switch (err) { const handle = switch (err) {
.type_error => |str| self.local.isolate.createTypeError(str), .type_error => |str| self.local.isolate.createTypeError(str),
.generic => |str| self.local.isolate.createError(str), .generic => |str| self.local.isolate.createError(str),
.dom_exception => |exception| {
self.reject(source, DOMException.fromError(exception));
return;
},
}; };
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| { self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false }); log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });

View File

@@ -725,6 +725,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/collections.zig"), @import("../webapi/collections.zig"),
@import("../webapi/Console.zig"), @import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"), @import("../webapi/Crypto.zig"),
@import("../webapi/Permissions.zig"),
@import("../webapi/StorageManager.zig"),
@import("../webapi/CSS.zig"), @import("../webapi/CSS.zig"),
@import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRule.zig"),
@import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSRuleList.zig"),

View File

@@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool {
return false; return false;
} }
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { const Context = struct {
if (!state.last_char_was_newline) { state: State,
try writer.writeByte('\n'); writer: *std.Io.Writer,
state.last_char_was_newline = true; page: *Page,
fn ensureNewline(self: *Context) !void {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
}
} }
}
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try self.renderChildren(node);
},
.element => |el| {
try self.renderElement(el);
},
.cdata => |cd| {
if (node.is(Node.CData.Text)) |_| {
var text = cd.getData().str();
if (self.state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n");
}
}
try self.renderText(text);
}
},
else => {},
}
}
fn renderChildren(self: *Context, parent: *Node) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try self.render(child);
}
}
fn renderElement(self: *Context, el: *Element) !void {
const tag = el.getTag();
if (!isVisibleElement(el)) return;
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
if (shouldAddSpacing(tag)) {
try self.writer.writeByte('\n');
}
} else if (tag == .li or tag == .tr) {
try self.ensureNewline();
}
// Prefixes
switch (tag) {
.h1 => try self.writer.writeAll("# "),
.h2 => try self.writer.writeAll("## "),
.h3 => try self.writer.writeAll("### "),
.h4 => try self.writer.writeAll("#### "),
.h5 => try self.writer.writeAll("##### "),
.h6 => try self.writer.writeAll("###### "),
.ul => {
if (self.state.list_depth < self.state.list_stack.len) {
self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
self.state.list_depth += 1;
}
},
.ol => {
if (self.state.list_depth < self.state.list_stack.len) {
self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
self.state.list_depth += 1;
}
},
.li => {
const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
for (0..indent) |_| try self.writer.writeAll(" ");
if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
const current_list = &self.state.list_stack[self.state.list_depth - 1];
try self.writer.print("{d}. ", .{current_list.index});
current_list.index += 1;
} else {
try self.writer.writeAll("- ");
}
self.state.last_char_was_newline = false;
},
.table => {
self.state.in_table = true;
self.state.table_row_index = 0;
self.state.table_col_count = 0;
},
.tr => {
self.state.table_col_count = 0;
try self.writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
self.state.last_char_was_newline = false;
try self.writer.writeByte(' ');
},
.blockquote => {
try self.writer.writeAll("> ");
self.state.last_char_was_newline = false;
},
.pre => {
try self.writer.writeAll("```\n");
self.state.pre_node = el.asNode();
self.state.last_char_was_newline = true;
},
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = true;
self.state.last_char_was_newline = false;
}
},
.b, .strong => {
try self.writer.writeAll("**");
self.state.last_char_was_newline = false;
},
.i, .em => {
try self.writer.writeAll("*");
self.state.last_char_was_newline = false;
},
.s, .del => {
try self.writer.writeAll("~~");
self.state.last_char_was_newline = false;
},
.hr => {
try self.writer.writeAll("---\n");
self.state.last_char_was_newline = true;
return;
},
.br => {
if (self.state.in_table) {
try self.writer.writeByte(' ');
} else {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
}
return;
},
.img => {
try self.writer.writeAll("![");
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try self.escape(alt);
}
try self.writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src;
try self.writer.writeAll(absolute_src);
}
try self.writer.writeAll(")");
self.state.last_char_was_newline = false;
return;
},
.anchor => {
const has_content = hasVisibleContent(el.asNode());
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
if (!has_content and label == null and href_raw == null) return;
const has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
try self.renderChildren(el.asNode());
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeAll("([](");
try self.writer.writeAll(h);
try self.writer.writeAll("))\n");
self.state.last_char_was_newline = true;
}
return;
}
if (isStandaloneAnchor(el)) {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
if (has_content) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeAll(")\n");
self.state.last_char_was_newline = true;
return;
}
try self.writer.writeByte('[');
if (has_content) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeByte(')');
self.state.last_char_was_newline = false;
return;
},
.input => {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
self.state.last_char_was_newline = false;
}
return;
},
else => {},
}
// --- Render Children ---
try self.renderChildren(el.asNode());
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte('\n');
}
try self.writer.writeAll("```\n");
self.state.pre_node = null;
self.state.last_char_was_newline = true;
},
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = false;
self.state.last_char_was_newline = false;
}
},
.b, .strong => {
try self.writer.writeAll("**");
self.state.last_char_was_newline = false;
},
.i, .em => {
try self.writer.writeAll("*");
self.state.last_char_was_newline = false;
},
.s, .del => {
try self.writer.writeAll("~~");
self.state.last_char_was_newline = false;
},
.blockquote => {},
.ul, .ol => {
if (self.state.list_depth > 0) self.state.list_depth -= 1;
},
.table => {
self.state.in_table = false;
},
.tr => {
try self.writer.writeByte('\n');
if (self.state.table_row_index == 0) {
try self.writer.writeByte('|');
for (0..self.state.table_col_count) |_| {
try self.writer.writeAll("---|");
}
try self.writer.writeByte('\n');
}
self.state.table_row_index += 1;
self.state.last_char_was_newline = true;
},
.td, .th => {
try self.writer.writeAll(" |");
self.state.table_col_count += 1;
self.state.last_char_was_newline = false;
},
else => {},
}
// Post-block newlines
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
}
}
fn renderText(self: *Context, text: []const u8) !void {
if (text.len == 0) return;
if (self.state.pre_node) |_| {
try self.writer.writeAll(text);
self.state.last_char_was_newline = text[text.len - 1] == '\n';
return;
}
// Check for pure whitespace
if (isAllWhitespace(text)) {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte(' ');
}
return;
}
// Collapse whitespace
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
var first = true;
while (it.next()) |word| {
if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
try self.writer.writeByte(' ');
}
try self.escape(word);
self.state.last_char_was_newline = false;
first = false;
}
// Handle trailing whitespace from the original text
if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
try self.writer.writeByte(' ');
}
}
fn escape(self: *Context, text: []const u8) !void {
for (text) |c| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
try self.writer.writeByte('\\');
try self.writer.writeByte(c);
},
else => try self.writer.writeByte(c),
}
}
}
};
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void { pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts; _ = opts;
var state = State{}; var ctx: Context = .{
try render(node, &state, writer, page); .state = .{},
if (!state.last_char_was_newline) { .writer = writer,
.page = page,
};
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n'); try writer.writeByte('\n');
} }
} }
fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try renderChildren(node, state, writer, page);
},
.element => |el| {
try renderElement(el, state, writer, page);
},
.cdata => |cd| {
if (node.is(Node.CData.Text)) |_| {
var text = cd.getData().str();
if (state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n");
}
}
try renderText(text, state, writer);
}
},
else => {},
}
}
fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try render(child, state, writer, page);
}
}
fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
const tag = el.getTag();
if (!isVisibleElement(el)) return;
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (tag.isBlock() and !state.in_table) {
try ensureNewline(state, writer);
if (shouldAddSpacing(tag)) {
try writer.writeByte('\n');
}
} else if (tag == .li or tag == .tr) {
try ensureNewline(state, writer);
}
// Prefixes
switch (tag) {
.h1 => try writer.writeAll("# "),
.h2 => try writer.writeAll("## "),
.h3 => try writer.writeAll("### "),
.h4 => try writer.writeAll("#### "),
.h5 => try writer.writeAll("##### "),
.h6 => try writer.writeAll("###### "),
.ul => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
state.list_depth += 1;
}
},
.ol => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
state.list_depth += 1;
}
},
.li => {
const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
for (0..indent) |_| try writer.writeAll(" ");
if (state.list_depth > 0 and state.list_stack[state.list_depth - 1].type == .ordered) {
const current_list = &state.list_stack[state.list_depth - 1];
try writer.print("{d}. ", .{current_list.index});
current_list.index += 1;
} else {
try writer.writeAll("- ");
}
state.last_char_was_newline = false;
},
.table => {
state.in_table = true;
state.table_row_index = 0;
state.table_col_count = 0;
},
.tr => {
state.table_col_count = 0;
try writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
state.last_char_was_newline = false;
try writer.writeByte(' ');
},
.blockquote => {
try writer.writeAll("> ");
state.last_char_was_newline = false;
},
.pre => {
try writer.writeAll("```\n");
state.pre_node = el.asNode();
state.last_char_was_newline = true;
},
.code => {
if (state.pre_node == null) {
try writer.writeByte('`');
state.in_code = true;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.hr => {
try writer.writeAll("---\n");
state.last_char_was_newline = true;
return;
},
.br => {
if (state.in_table) {
try writer.writeByte(' ');
} else {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
return;
},
.img => {
try writer.writeAll("![");
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try escapeMarkdown(writer, alt);
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
try writer.writeAll(absolute_src);
}
try writer.writeAll(")");
state.last_char_was_newline = false;
return;
},
.anchor => {
const has_content = hasVisibleContent(el.asNode());
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
if (!has_content and label == null and href_raw == null) return;
const has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
try renderChildren(el.asNode(), state, writer, page);
if (href) |h| {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeAll("([](");
try writer.writeAll(h);
try writer.writeAll("))\n");
state.last_char_was_newline = true;
}
return;
}
if (isStandaloneAnchor(el)) {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeAll(")\n");
state.last_char_was_newline = true;
return;
}
try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeByte(')');
state.last_char_was_newline = false;
return;
},
.input => {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
try writer.writeAll(if (checked) "[x] " else "[ ] ");
state.last_char_was_newline = false;
}
return;
},
else => {},
}
// --- Render Children ---
try renderChildren(el.asNode(), state, writer, page);
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
}
try writer.writeAll("```\n");
state.pre_node = null;
state.last_char_was_newline = true;
},
.code => {
if (state.pre_node == null) {
try writer.writeByte('`');
state.in_code = false;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.blockquote => {},
.ul, .ol => {
if (state.list_depth > 0) state.list_depth -= 1;
},
.table => {
state.in_table = false;
},
.tr => {
try writer.writeByte('\n');
if (state.table_row_index == 0) {
try writer.writeByte('|');
for (0..state.table_col_count) |_| {
try writer.writeAll("---|");
}
try writer.writeByte('\n');
}
state.table_row_index += 1;
state.last_char_was_newline = true;
},
.td, .th => {
try writer.writeAll(" |");
state.table_col_count += 1;
state.last_char_was_newline = false;
},
else => {},
}
// Post-block newlines
if (tag.isBlock() and !state.in_table) {
try ensureNewline(state, writer);
}
}
fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
if (text.len == 0) return;
if (state.pre_node) |_| {
try writer.writeAll(text);
state.last_char_was_newline = text[text.len - 1] == '\n';
return;
}
// Check for pure whitespace
if (isAllWhitespace(text)) {
if (!state.last_char_was_newline) {
try writer.writeByte(' ');
}
return;
}
// Collapse whitespace
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
var first = true;
while (it.next()) |word| {
if (!first or (!state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
try writer.writeByte(' ');
}
try escapeMarkdown(writer, word);
state.last_char_was_newline = false;
first = false;
}
// Handle trailing whitespace from the original text
if (!first and !state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
try writer.writeByte(' ');
}
}
fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
for (text) |c| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
try writer.writeByte('\\');
try writer.writeByte(c);
},
else => try writer.writeByte(c),
}
}
}
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void { fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
const page = try testing.test_session.createPage(); const page = try testing.test_session.createPage();

View File

@@ -27,3 +27,44 @@
testing.expectEqual(false, navigator.javaEnabled()); testing.expectEqual(false, navigator.javaEnabled());
testing.expectEqual(false, navigator.webdriver); testing.expectEqual(false, navigator.webdriver);
</script> </script>
<script id=permission_query>
testing.async(async (restore) => {
const p = navigator.permissions.query({ name: 'notifications' });
testing.expectTrue(p instanceof Promise);
const status = await p;
restore();
testing.expectEqual('prompt', status.state);
testing.expectEqual('notifications', status.name);
});
</script>
<script id=storage_estimate>
testing.async(async (restore) => {
const p = navigator.storage.estimate();
testing.expectTrue(p instanceof Promise);
const estimate = await p;
restore();
testing.expectEqual(0, estimate.usage);
testing.expectEqual(1024 * 1024 * 1024, estimate.quota);
});
</script>
<script id=deviceMemory>
testing.expectEqual(8, navigator.deviceMemory);
</script>
<script id=getBattery>
testing.async(async (restore) => {
const p = navigator.getBattery();
try {
await p;
testing.fail('getBattery should reject');
} catch (err) {
restore();
testing.expectEqual('NotSupportedError', err.name);
}
});
</script>

View File

@@ -225,3 +225,17 @@
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
}); });
</script> </script>
<script id=abort>
testing.async(async (restore) => {
const controller = new AbortController();
controller.abort();
try {
await fetch('http://127.0.0.1:9582/xhr', { signal: controller.signal });
testain.fail('fetch should have been aborted');
} catch (e) {
restore();
testing.expectEqual("AbortError", e.name);
}
});
</script>

View File

@@ -18,13 +18,21 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const log = @import("../../log.zig");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const PluginArray = @import("PluginArray.zig"); const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const StorageManager = @import("StorageManager.zig");
const Navigator = @This(); const Navigator = @This();
_pad: bool = false, _pad: bool = false,
_plugins: PluginArray = .{}, _plugins: PluginArray = .{},
_permissions: Permissions = .{},
_storage: StorageManager = .{},
pub const init: Navigator = .{}; pub const init: Navigator = .{};
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
return &self._plugins; return &self._plugins;
} }
pub fn getPermissions(self: *Navigator) *Permissions {
return &self._permissions;
}
pub fn getStorage(self: *Navigator) *StorageManager {
return &self._storage;
}
pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise {
log.info(.not_implemented, "navigator.getBattery", .{});
return page.js.local.?.rejectErrorPromise(.{ .dom_exception = error.NotSupported });
}
pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void { pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerScheme(scheme);
try validateProtocolHandlerURL(url, page); try validateProtocolHandlerURL(url, page);
@@ -144,6 +165,7 @@ pub const JsApi = struct {
pub const onLine = bridge.property(true, .{ .template = false }); pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .template = false }); pub const cookieEnabled = bridge.property(true, .{ .template = false });
pub const hardwareConcurrency = bridge.property(4, .{ .template = false }); pub const hardwareConcurrency = bridge.property(4, .{ .template = false });
pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false });
pub const maxTouchPoints = bridge.property(0, .{ .template = false }); pub const maxTouchPoints = bridge.property(0, .{ .template = false });
pub const vendor = bridge.property("", .{ .template = false }); pub const vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false }); pub const product = bridge.property("Gecko", .{ .template = false });
@@ -156,4 +178,12 @@ pub const JsApi = struct {
// Methods // Methods
pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{});
pub const getBattery = bridge.function(Navigator.getBattery, .{});
pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
}; };
const testing = @import("../../testing.zig");
test "WebApi: Navigator" {
try testing.htmlRunner("navigator", .{});
}

View File

@@ -0,0 +1,94 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{ Permissions, PermissionStatus };
}
const Permissions = @This();
// Padding to avoid zero-size struct pointer collisions
_pad: bool = false,
const QueryDescriptor = struct {
name: []const u8,
};
// We always report 'prompt' (the default safe value — neither granted nor denied).
pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise {
const arena = try page.getArena(.{ .debug = "PermissionStatus" });
errdefer page.releaseArena(arena);
const status = try arena.create(PermissionStatus);
status.* = .{
._arena = arena,
._state = "prompt",
._name = try arena.dupe(u8, qd.name),
};
return page.js.local.?.resolvePromise(status);
}
const PermissionStatus = struct {
_arena: Allocator,
_name: []const u8,
_state: []const u8,
pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
fn getName(self: *const PermissionStatus) []const u8 {
return self._name;
}
fn getState(self: *const PermissionStatus) []const u8 {
return self._state;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(PermissionStatus);
pub const Meta = struct {
pub const name = "PermissionStatus";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(PermissionStatus.deinit);
};
pub const name = bridge.accessor(getName, null, .{});
pub const state = bridge.accessor(getState, null, .{});
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(Permissions);
pub const Meta = struct {
pub const name = "Permissions";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const query = bridge.function(Permissions.query, .{ .dom_exception = true });
};

View File

@@ -0,0 +1,71 @@
// 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 js = @import("../js/js.zig");
const Page = @import("../Page.zig");
pub fn registerTypes() []const type {
return &.{ StorageManager, StorageEstimate };
}
const StorageManager = @This();
_pad: bool = false,
pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise {
const est = try page._factory.create(StorageEstimate{
._usage = 0,
._quota = 1024 * 1024 * 1024, // 1 GiB
});
return page.js.local.?.resolvePromise(est);
}
const StorageEstimate = struct {
_quota: u64,
_usage: u64,
fn getUsage(self: *const StorageEstimate) u64 {
return self._usage;
}
fn getQuota(self: *const StorageEstimate) u64 {
return self._quota;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(StorageEstimate);
pub const Meta = struct {
pub const name = "StorageEstimate";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const quota = bridge.accessor(getQuota, null, .{});
pub const usage = bridge.accessor(getUsage, null, .{});
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(StorageManager);
pub const Meta = struct {
pub const name = "StorageManager";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const estimate = bridge.function(StorageManager.estimate, .{});
};

View File

@@ -28,6 +28,8 @@ const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig"); const Blob = @import("../Blob.zig");
const Request = @import("Request.zig"); const Request = @import("Request.zig");
const Response = @import("Response.zig"); const Response = @import("Response.zig");
const AbortSignal = @import("../AbortSignal.zig");
const DOMException = @import("../DOMException.zig");
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -39,6 +41,7 @@ _buf: std.ArrayList(u8),
_response: *Response, _response: *Response,
_resolver: js.PromiseResolver.Global, _resolver: js.PromiseResolver.Global,
_owns_response: bool, _owns_response: bool,
_signal: ?*AbortSignal,
pub const Input = Request.Input; pub const Input = Request.Input;
pub const InitOpts = Request.InitOpts; pub const InitOpts = Request.InitOpts;
@@ -47,6 +50,13 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
const request = try Request.init(input, options, page); const request = try Request.init(input, options, page);
const resolver = page.js.local.?.createPromiseResolver(); const resolver = page.js.local.?.createPromiseResolver();
if (request._signal) |signal| {
if (signal._aborted) {
resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError"));
return resolver.promise();
}
}
if (std.mem.startsWith(u8, request._url, "blob:")) { if (std.mem.startsWith(u8, request._url, "blob:")) {
return handleBlobUrl(request._url, resolver, page); return handleBlobUrl(request._url, resolver, page);
} }
@@ -62,6 +72,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
._resolver = try resolver.persist(), ._resolver = try resolver.persist(),
._response = response, ._response = response,
._owns_response = true, ._owns_response = true,
._signal = request._signal,
}; };
const http_client = page._session.browser.http_client; const http_client = page._session.browser.http_client;
@@ -126,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
if (self._signal) |signal| {
if (signal._aborted) {
return false;
}
}
const arena = self._response._arena; const arena = self._response._arena;
if (transfer.getContentLength()) |cl| { if (transfer.getContentLength()) |cl| {
try self._buf.ensureTotalCapacity(arena, cl); try self._buf.ensureTotalCapacity(arena, cl);
@@ -175,6 +192,14 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
// Check if aborted
if (self._signal) |signal| {
if (signal._aborted) {
return error.Abort;
}
}
try self._buf.appendSlice(self._response._arena, data); try self._buf.appendSlice(self._response._arena, data);
} }

View File

@@ -25,6 +25,7 @@ const URL = @import("../URL.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig"); const Blob = @import("../Blob.zig");
const AbortSignal = @import("../AbortSignal.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Request = @This(); const Request = @This();
@@ -36,6 +37,7 @@ _body: ?[]const u8,
_arena: Allocator, _arena: Allocator,
_cache: Cache, _cache: Cache,
_credentials: Credentials, _credentials: Credentials,
_signal: ?*AbortSignal,
pub const Input = union(enum) { pub const Input = union(enum) {
request: *Request, request: *Request,
@@ -48,6 +50,7 @@ pub const InitOpts = struct {
body: ?[]const u8 = null, body: ?[]const u8 = null,
cache: Cache = .default, cache: Cache = .default,
credentials: Credentials = .@"same-origin", credentials: Credentials = .@"same-origin",
signal: ?*AbortSignal = null,
}; };
const Credentials = enum { const Credentials = enum {
@@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
.request => |r| r._body, .request => |r| r._body,
}; };
const signal = if (opts.signal) |s|
s
else switch (input) {
.url => null,
.request => |r| r._signal,
};
return page._factory.create(Request{ return page._factory.create(Request{
._url = url, ._url = url,
._arena = arena, ._arena = arena,
@@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
._cache = opts.cache, ._cache = opts.cache,
._credentials = opts.credentials, ._credentials = opts.credentials,
._body = body, ._body = body,
._signal = signal,
}); });
} }
@@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 {
return @tagName(self._credentials); return @tagName(self._credentials);
} }
pub fn getSignal(self: *const Request) ?*AbortSignal {
return self._signal;
}
pub fn getHeaders(self: *Request, page: *Page) !*Headers { pub fn getHeaders(self: *Request, page: *Page) !*Headers {
if (self._headers) |headers| { if (self._headers) |headers| {
return headers; return headers;
@@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request {
._cache = self._cache, ._cache = self._cache,
._credentials = self._credentials, ._credentials = self._credentials,
._body = self._body, ._body = self._body,
._signal = self._signal,
}); });
} }
@@ -218,6 +234,7 @@ pub const JsApi = struct {
pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const headers = bridge.accessor(Request.getHeaders, null, .{});
pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{});
pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{});
pub const signal = bridge.accessor(Request.getSignal, null, .{});
pub const blob = bridge.function(Request.blob, .{}); pub const blob = bridge.function(Request.blob, .{});
pub const text = bridge.function(Request.text, .{}); pub const text = bridge.function(Request.text, .{});
pub const json = bridge.function(Request.json, .{}); pub const json = bridge.function(Request.json, .{});

View File

@@ -53,12 +53,18 @@ fn getSemanticTree(cmd: anytype) !void {
format: ?enum { text } = null, format: ?enum { text } = null,
prune: ?bool = null, prune: ?bool = null,
interactiveOnly: ?bool = null, interactiveOnly: ?bool = null,
backendNodeId: ?Node.Id = null,
maxDepth: ?u32 = null,
}; };
const params = (try cmd.params(Params)) orelse Params{}; const params = (try cmd.params(Params)) orelse Params{};
const bc = cmd.browser_context orelse return error.NoBrowserContext; const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const dom_node = page.document.asNode();
const dom_node = if (params.backendNodeId) |nodeId|
(bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom
else
page.document.asNode();
var st = SemanticTree{ var st = SemanticTree{
.dom_node = dom_node, .dom_node = dom_node,
@@ -67,6 +73,7 @@ fn getSemanticTree(cmd: anytype) !void {
.arena = cmd.arena, .arena = cmd.arena,
.prune = params.prune orelse true, .prune = params.prune orelse true,
.interactive_only = params.interactiveOnly orelse false, .interactive_only = params.interactiveOnly orelse false,
.max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1,
}; };
if (params.format) |format| { if (params.format) |format| {

View File

@@ -70,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{
\\{ \\{
\\ "type": "object", \\ "type": "object",
\\ "properties": { \\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." } \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ } \\ }
\\} \\}
), ),
@@ -161,6 +163,8 @@ const ToolStreamingText = struct {
action: enum { markdown, links, semantic_tree }, action: enum { markdown, links, semantic_tree },
registry: ?*CDPNode.Registry = null, registry: ?*CDPNode.Registry = null,
arena: ?std.mem.Allocator = null, arena: ?std.mem.Allocator = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw(); try jw.beginWriteRaw();
@@ -196,12 +200,24 @@ const ToolStreamingText = struct {
} }
}, },
.semantic_tree => { .semantic_tree => {
var root_node = self.page.document.asNode();
if (self.backendNodeId) |node_id| {
if (self.registry) |registry| {
if (registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
} else {
log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id });
}
}
}
const st = lp.SemanticTree{ const st = lp.SemanticTree{
.dom_node = self.page.document.asNode(), .dom_node = root_node,
.registry = self.registry.?, .registry = self.registry.?,
.page = self.page, .page = self.page,
.arena = self.arena.?, .arena = self.arena.?,
.prune = true, .prune = true,
.max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1,
}; };
st.textStringify(w) catch |err| { st.textStringify(w) catch |err| {
@@ -328,9 +344,13 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const TreeParams = struct { const TreeParams = struct {
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
}; };
var tree_args: TreeParams = .{};
if (arguments) |args_raw| { if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
tree_args = args;
if (args.url) |u| { if (args.url) |u| {
try performGoto(server, u, id); try performGoto(server, u, id);
} }
@@ -341,7 +361,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
}; };
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena }, .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth },
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
} }