diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index 34405913..bf09540a 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -192,13 +192,15 @@ jobs:
- 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
shell: bash
run: |
-
node webbotauth/validator.js &
VALIDATOR_PID=$!
- sleep 2
+ sleep 5
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml
index cb8497f9..029228cd 100644
--- a/.github/workflows/wpt.yml
+++ b/.github/workflows/wpt.yml
@@ -10,7 +10,7 @@ env:
on:
schedule:
- - cron: "23 2 * * *"
+ - cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -19,8 +19,12 @@ jobs:
wpt-build-release:
name: zig build release
- runs-on: ubuntu-latest
- timeout-minutes: 15
+ env:
+ ARCH: aarch64
+ OS: linux
+
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v6
@@ -28,9 +32,15 @@ jobs:
fetch-depth: 0
- 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
- 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
uses: actions/upload-artifact@v7
@@ -43,7 +53,7 @@ jobs:
wpt-build-runner:
name: build wpt runner
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
timeout-minutes: 15
steps:
@@ -71,8 +81,8 @@ jobs:
- wpt-build-runner
# use a self host runner.
- runs-on: lpd-bench-hetzner
- timeout-minutes: 180
+ runs-on: lpd-wpt-aws
+ timeout-minutes: 600
steps:
- uses: actions/checkout@v6
@@ -105,8 +115,8 @@ jobs:
- name: run test with json output
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
- sleep 10s
- ./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
+ sleep 20s
+ ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid`
- name: write commit
diff --git a/Makefile b/Makefile
index 462761b1..922c77a7 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ help:
# $(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:
@@ -77,11 +77,6 @@ run-debug: build-dev
@printf "\033[36mRunning...\033[0m\n"
@./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
ifeq ($(OS), macos)
test:
@@ -106,4 +101,3 @@ install: build
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
-
diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig
index 166acf4a..af8720e9 100644
--- a/src/SemanticTree.zig
+++ b/src/SemanticTree.zig
@@ -38,6 +38,7 @@ page: *Page,
arena: std.mem.Allocator,
prune: bool = true,
interactive_only: bool = false,
+max_depth: u32 = std.math.maxInt(u32) - 1,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
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 });
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 });
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 });
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 });
return error.WriteFailed;
};
@@ -72,7 +73,7 @@ const OptionData = struct {
};
const NodeData = struct {
- id: u32,
+ id: CDPNode.Id,
axn: AXNode,
role: []const u8,
name: ?[]const u8,
@@ -83,7 +84,9 @@ const NodeData = struct {
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
if (node.is(Element)) |el| {
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;
- 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 = ®istry,
+ .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 = ®istry,
+ .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);
+}
diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig
index a45b35df..f913dafd 100644
--- a/src/browser/js/Local.zig
+++ b/src/browser/js/Local.zig
@@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.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 {
var resolver = js.PromiseResolver.init(self);
resolver.resolve("Local.resolvePromise", value);
diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig
index 67f04311..6386569a 100644
--- a/src/browser/js/PromiseResolver.zig
+++ b/src/browser/js/PromiseResolver.zig
@@ -18,7 +18,9 @@
const js = @import("js.zig");
const v8 = js.v8;
+
const log = @import("../../log.zig");
+const DOMException = @import("../webapi/DOMException.zig");
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,
type_error: []const u8,
+ dom_exception: anyerror,
};
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
const handle = switch (err) {
.type_error => |str| self.local.isolate.createTypeError(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| {
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 4fc96b7e..2f5d0bb9 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -725,6 +725,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/collections.zig"),
@import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"),
+ @import("../webapi/Permissions.zig"),
+ @import("../webapi/StorageManager.zig"),
@import("../webapi/CSS.zig"),
@import("../webapi/css/CSSRule.zig"),
@import("../webapi/css/CSSRuleList.zig"),
diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig
index 8a4984a4..f4a0f874 100644
--- a/src/browser/markdown.zig
+++ b/src/browser/markdown.zig
@@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool {
return false;
}
-fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
- if (!state.last_char_was_newline) {
- try writer.writeByte('\n');
- state.last_char_was_newline = true;
+const Context = struct {
+ state: State,
+ writer: *std.Io.Writer,
+ 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("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 {
_ = opts;
- var state = State{};
- try render(node, &state, writer, page);
- if (!state.last_char_was_newline) {
+ var ctx: Context = .{
+ .state = .{},
+ .writer = writer,
+ .page = page,
+ };
+ try ctx.render(node);
+ if (!ctx.state.last_char_was_newline) {
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("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 {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/navigator/navigator.html
similarity index 56%
rename from src/browser/tests/window/navigator.html
rename to src/browser/tests/navigator/navigator.html
index df9f436a..b82c6e38 100644
--- a/src/browser/tests/window/navigator.html
+++ b/src/browser/tests/navigator/navigator.html
@@ -27,3 +27,44 @@
testing.expectEqual(false, navigator.javaEnabled());
testing.expectEqual(false, navigator.webdriver);
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html
index 10ce6677..229d8469 100644
--- a/src/browser/tests/net/fetch.html
+++ b/src/browser/tests/net/fetch.html
@@ -225,3 +225,17 @@
URL.revokeObjectURL(blobUrl);
});
+
+
diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig
index 5e69db07..218110c9 100644
--- a/src/browser/webapi/Navigator.zig
+++ b/src/browser/webapi/Navigator.zig
@@ -18,13 +18,21 @@
const std = @import("std");
const builtin = @import("builtin");
+
+const log = @import("../../log.zig");
+
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
+
const PluginArray = @import("PluginArray.zig");
+const Permissions = @import("Permissions.zig");
+const StorageManager = @import("StorageManager.zig");
const Navigator = @This();
_pad: bool = false,
_plugins: PluginArray = .{},
+_permissions: Permissions = .{},
+_storage: StorageManager = .{},
pub const init: Navigator = .{};
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
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 {
try validateProtocolHandlerScheme(scheme);
try validateProtocolHandlerURL(url, page);
@@ -144,6 +165,7 @@ pub const JsApi = struct {
pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .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 vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false });
@@ -156,4 +178,12 @@ pub const JsApi = struct {
// Methods
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", .{});
+}
diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig
new file mode 100644
index 00000000..ee197d3f
--- /dev/null
+++ b/src/browser/webapi/Permissions.zig
@@ -0,0 +1,94 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+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 });
+};
diff --git a/src/browser/webapi/StorageManager.zig b/src/browser/webapi/StorageManager.zig
new file mode 100644
index 00000000..e7b95cc4
--- /dev/null
+++ b/src/browser/webapi/StorageManager.zig
@@ -0,0 +1,71 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+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, .{});
+};
diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig
index 7ab7b8ec..0a44aae2 100644
--- a/src/browser/webapi/net/Fetch.zig
+++ b/src/browser/webapi/net/Fetch.zig
@@ -28,6 +28,8 @@ const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig");
const Request = @import("Request.zig");
const Response = @import("Response.zig");
+const AbortSignal = @import("../AbortSignal.zig");
+const DOMException = @import("../DOMException.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -39,6 +41,7 @@ _buf: std.ArrayList(u8),
_response: *Response,
_resolver: js.PromiseResolver.Global,
_owns_response: bool,
+_signal: ?*AbortSignal,
pub const Input = Request.Input;
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 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:")) {
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(),
._response = response,
._owns_response = true,
+ ._signal = request._signal,
};
const http_client = page._session.browser.http_client;
@@ -126,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
+ if (self._signal) |signal| {
+ if (signal._aborted) {
+ return false;
+ }
+ }
+
const arena = self._response._arena;
if (transfer.getContentLength()) |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 {
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);
}
diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig
index 3d3ca825..6ba8f224 100644
--- a/src/browser/webapi/net/Request.zig
+++ b/src/browser/webapi/net/Request.zig
@@ -25,6 +25,7 @@ const URL = @import("../URL.zig");
const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig");
+const AbortSignal = @import("../AbortSignal.zig");
const Allocator = std.mem.Allocator;
const Request = @This();
@@ -36,6 +37,7 @@ _body: ?[]const u8,
_arena: Allocator,
_cache: Cache,
_credentials: Credentials,
+_signal: ?*AbortSignal,
pub const Input = union(enum) {
request: *Request,
@@ -48,6 +50,7 @@ pub const InitOpts = struct {
body: ?[]const u8 = null,
cache: Cache = .default,
credentials: Credentials = .@"same-origin",
+ signal: ?*AbortSignal = null,
};
const Credentials = enum {
@@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
.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{
._url = url,
._arena = arena,
@@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
._cache = opts.cache,
._credentials = opts.credentials,
._body = body,
+ ._signal = signal,
});
}
@@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 {
return @tagName(self._credentials);
}
+pub fn getSignal(self: *const Request) ?*AbortSignal {
+ return self._signal;
+}
+
pub fn getHeaders(self: *Request, page: *Page) !*Headers {
if (self._headers) |headers| {
return headers;
@@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request {
._cache = self._cache,
._credentials = self._credentials,
._body = self._body,
+ ._signal = self._signal,
});
}
@@ -218,6 +234,7 @@ pub const JsApi = struct {
pub const headers = bridge.accessor(Request.getHeaders, null, .{});
pub const cache = bridge.accessor(Request.getCache, 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 text = bridge.function(Request.text, .{});
pub const json = bridge.function(Request.json, .{});
diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig
index efbf9ec7..51b3249d 100644
--- a/src/cdp/domains/lp.zig
+++ b/src/cdp/domains/lp.zig
@@ -53,12 +53,18 @@ fn getSemanticTree(cmd: anytype) !void {
format: ?enum { text } = null,
prune: ?bool = null,
interactiveOnly: ?bool = null,
+ backendNodeId: ?Node.Id = null,
+ maxDepth: ?u32 = null,
};
const params = (try cmd.params(Params)) orelse Params{};
const bc = cmd.browser_context orelse return error.NoBrowserContext;
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{
.dom_node = dom_node,
@@ -67,6 +73,7 @@ fn getSemanticTree(cmd: anytype) !void {
.arena = cmd.arena,
.prune = params.prune orelse true,
.interactive_only = params.interactiveOnly orelse false,
+ .max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1,
};
if (params.format) |format| {
diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig
index d8fd4ead..aefcca83 100644
--- a/src/mcp/tools.zig
+++ b/src/mcp/tools.zig
@@ -70,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{
\\{
\\ "type": "object",
\\ "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 },
registry: ?*CDPNode.Registry = null,
arena: ?std.mem.Allocator = null,
+ backendNodeId: ?u32 = null,
+ maxDepth: ?u32 = null,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw();
@@ -196,12 +200,24 @@ const ToolStreamingText = struct {
}
},
.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{
- .dom_node = self.page.document.asNode(),
+ .dom_node = root_node,
.registry = self.registry.?,
.page = self.page,
.arena = self.arena.?,
.prune = true,
+ .max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1,
};
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 {
const TreeParams = struct {
url: ?[:0]const u8 = null,
+ backendNodeId: ?u32 = null,
+ maxDepth: ?u32 = null,
};
+ var tree_args: TreeParams = .{};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
+ tree_args = args;
if (args.url) |u| {
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){.{
- .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 });
}