From 0f46277b1faaa61c6e9f667920193a995363348b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 14:47:29 +0900 Subject: [PATCH 01/32] CDP: implement LP.getSemanticTree for native semantic DOM extraction --- src/browser/EventManager.zig | 8 ++ src/cdp/AXNode.zig | 2 +- src/cdp/domains/lp.zig | 36 +++++- src/cdp/semantic_tree.zig | 221 +++++++++++++++++++++++++++++++++++ src/lightpanda.zig | 1 + 5 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/cdp/semantic_tree.zig diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 17271635..52485256 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -98,6 +98,14 @@ pub const Callback = union(enum) { object: js.Object, }; +pub fn hasListener(self: *EventManager, target: *EventTarget, typ: []const u8) bool { + const type_string = String.wrap(typ); + return self.lookup.contains(.{ + .event_target = @intFromPtr(target), + .type_string = type_string, + }); +} + pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void { if (comptime IS_DEBUG) { log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() }); diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 487e79ad..d7c68db2 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -987,7 +987,7 @@ fn isIgnore(self: AXNode, page: *Page) bool { return false; } -fn getRole(self: AXNode) ![]const u8 { +pub fn getRole(self: AXNode) ![]const u8 { if (self.role_attr) |role_value| { // TODO the role can have multiple comma separated values. return role_value; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 5503c356..a5fcffe6 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -18,19 +18,53 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = @import("../../log.zig"); const markdown = lp.markdown; const Node = @import("../Node.zig"); +const DOMNode = @import("../../browser/webapi/Node.zig"); +const SemanticTree = @import("../semantic_tree.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getMarkdown, + getSemanticTree, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getMarkdown => return getMarkdown(cmd), + .getSemanticTree => return getSemanticTree(cmd), } } +const SemanticTreeResult = struct { + dom_node: *DOMNode, + registry: *Node.Registry, + page: *lp.Page, + arena: std.mem.Allocator, + + pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { + SemanticTree.dump(self.dom_node, self.registry, jw, self.page, self.arena) catch |err| { + log.err(.cdp, "semantic tree dump failed", .{ .err = err }); + return error.WriteFailed; + }; + } +}; + +fn getSemanticTree(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + const dom_node = page.document.asNode(); + + return cmd.sendResult(.{ + .semanticTree = .{ + .dom_node = dom_node, + .registry = &bc.node_registry, + .page = page, + .arena = cmd.arena, + }, + }, .{}); +} + fn getMarkdown(cmd: anytype) !void { const Params = struct { nodeId: ?Node.Id = null, @@ -45,7 +79,7 @@ fn getMarkdown(cmd: anytype) !void { else page.document.asNode(); - var aw = std.Io.Writer.Allocating.init(cmd.arena); + var aw: std.Io.Writer.Allocating = .init(cmd.arena); defer aw.deinit(); try markdown.dump(dom_node, .{}, &aw.writer, page); diff --git a/src/cdp/semantic_tree.zig b/src/cdp/semantic_tree.zig new file mode 100644 index 00000000..d3b637f4 --- /dev/null +++ b/src/cdp/semantic_tree.zig @@ -0,0 +1,221 @@ +// 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 std = @import("std"); + +const lp = @import("lightpanda"); +const Page = lp.Page; + +const CData = @import("../browser/webapi/CData.zig"); +const Element = @import("../browser/webapi/Element.zig"); +const Node = @import("../browser/webapi/Node.zig"); +const AXNode = @import("AXNode.zig"); +const CDPNode = @import("Node.zig"); + +pub fn dump(root: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, page: *Page, arena: std.mem.Allocator) !void { + try dumpNode(root, registry, jw, page, "", arena); +} + +fn isAllWhitespace(text: []const u8) bool { + for (text) |c| { + if (!std.ascii.isWhitespace(c)) return false; + } + return true; +} + +fn getXPathSegment(node: *Node, arena: std.mem.Allocator) ![]const u8 { + if (node.is(Element)) |el| { + const tag = el.getTagNameLower(); + var index: usize = 1; + + if (node._parent) |parent| { + var it = parent.childrenIterator(); + while (it.next()) |sibling| { + if (sibling == node) break; + if (sibling.is(Element)) |s_el| { + if (std.mem.eql(u8, s_el.getTagNameLower(), tag)) { + index += 1; + } + } + } + } + return std.fmt.allocPrint(arena, "/{s}[{d}]", .{ tag, index }); + } else if (node.is(CData.Text) != null) { + var index: usize = 1; + if (node._parent) |parent| { + var it = parent.childrenIterator(); + while (it.next()) |sibling| { + if (sibling == node) break; + if (sibling.is(CData.Text) != null) { + index += 1; + } + } + } + return std.fmt.allocPrint(arena, "/text()[{d}]", .{index}); + } + return ""; +} + +fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, page: *Page, parent_xpath: []const u8, arena: std.mem.Allocator) !void { + // 1. Skip non-content nodes + if (node.is(Element)) |el| { + const tag = el.getTagNameLower(); + if (std.mem.eql(u8, tag, "script") or + std.mem.eql(u8, tag, "style") or + std.mem.eql(u8, tag, "meta") or + std.mem.eql(u8, tag, "link") or + std.mem.eql(u8, tag, "noscript") or + std.mem.eql(u8, tag, "svg") or + std.mem.eql(u8, tag, "head") or + std.mem.eql(u8, tag, "title")) + { + return; + } + + // CSS display: none visibility check (inline style only for now) + if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { + if (std.mem.indexOf(u8, style, "display: none") != null or + std.mem.indexOf(u8, style, "display:none") != null) + { + return; + } + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.getHidden()) return; + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + const text = text_node.getWholeText(); + if (isAllWhitespace(text)) { + return; + } + } else if (node._type != .document and node._type != .document_fragment) { + return; + } + + const cdp_node = try registry.register(node); + const axn = AXNode.fromNode(node); + + const role = try axn.getRole(); + + var is_interactive = false; + var node_name: []const u8 = "text"; + + if (node.is(Element)) |el| { + node_name = el.getTagNameLower(); + + if (std.mem.eql(u8, role, "button") or + std.mem.eql(u8, role, "link") or + std.mem.eql(u8, role, "checkbox") or + std.mem.eql(u8, role, "radio") or + std.mem.eql(u8, role, "textbox") or + std.mem.eql(u8, role, "combobox") or + std.mem.eql(u8, role, "searchbox") or + std.mem.eql(u8, role, "slider") or + std.mem.eql(u8, role, "spinbutton") or + std.mem.eql(u8, role, "switch") or + std.mem.eql(u8, role, "menuitem")) + { + is_interactive = true; + } + + const event_target = node.asEventTarget(); + if (page._event_manager.hasListener(event_target, "click") or + page._event_manager.hasListener(event_target, "mousedown") or + page._event_manager.hasListener(event_target, "mouseup") or + page._event_manager.hasListener(event_target, "keydown") or + page._event_manager.hasListener(event_target, "change") or + page._event_manager.hasListener(event_target, "input")) + { + is_interactive = true; + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.hasAttributeFunction(.onclick, page) or + html_el.hasAttributeFunction(.onmousedown, page) or + html_el.hasAttributeFunction(.onmouseup, page) or + html_el.hasAttributeFunction(.onkeydown, page) or + html_el.hasAttributeFunction(.onchange, page) or + html_el.hasAttributeFunction(.oninput, page)) + { + is_interactive = true; + } + } + } else if (node._type == .document or node._type == .document_fragment) { + node_name = "root"; + } + + const segment = try getXPathSegment(node, arena); + const xpath = try std.mem.concat(arena, u8, &.{ parent_xpath, segment }); + + try jw.beginObject(); + + try jw.objectField("nodeId"); + try jw.write(cdp_node.id); + + try jw.objectField("backendNodeId"); + try jw.write(cdp_node.id); + + try jw.objectField("nodeName"); + try jw.write(node_name); + + try jw.objectField("xpath"); + try jw.write(xpath); + + if (node.is(Element)) |el| { + try jw.objectField("nodeType"); + try jw.write(1); + + try jw.objectField("isInteractive"); + try jw.write(is_interactive); + + try jw.objectField("role"); + try jw.write(role); + + if (el._attributes) |attrs| { + try jw.objectField("attributes"); + try jw.beginObject(); + var iter = attrs.iterator(); + while (iter.next()) |attr| { + try jw.objectField(attr._name.str()); + try jw.write(attr._value.str()); + } + try jw.endObject(); + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + try jw.objectField("nodeType"); + try jw.write(3); + try jw.objectField("nodeValue"); + try jw.write(text_node.getWholeText()); + } else { + try jw.objectField("nodeType"); + try jw.write(9); + } + + try jw.objectField("children"); + try jw.beginArray(); + var it = node.childrenIterator(); + while (it.next()) |child| { + try dumpNode(child, registry, jw, page, xpath, arena); + } + try jw.endArray(); + + try jw.endObject(); +} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 26bc23f0..b1bbed59 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -21,6 +21,7 @@ pub const App = @import("App.zig"); pub const Server = @import("Server.zig"); pub const Config = @import("Config.zig"); pub const URL = @import("browser/URL.zig"); +pub const String = @import("string.zig").String; pub const Page = @import("browser/Page.zig"); pub const Browser = @import("browser/Browser.zig"); pub const Session = @import("browser/Session.zig"); From 248851701f7b06b3df7fac4b1dc233282a03067c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 15:44:03 +0900 Subject: [PATCH 02/32] Refactor: move SemanticTree to core and expose via MCP tools --- .../semantic_tree.zig => SemanticTree.zig} | 69 ++++++++------- src/cdp/domains/lp.zig | 18 +--- src/lightpanda.zig | 1 + src/mcp/Server.zig | 4 + src/mcp/tools.zig | 83 ++++++++++++++++--- 5 files changed, 119 insertions(+), 56 deletions(-) rename src/{cdp/semantic_tree.zig => SemanticTree.zig} (72%) diff --git a/src/cdp/semantic_tree.zig b/src/SemanticTree.zig similarity index 72% rename from src/cdp/semantic_tree.zig rename to src/SemanticTree.zig index d3b637f4..cf76acec 100644 --- a/src/cdp/semantic_tree.zig +++ b/src/SemanticTree.zig @@ -19,26 +19,37 @@ const std = @import("std"); const lp = @import("lightpanda"); +const log = @import("log.zig"); const Page = lp.Page; -const CData = @import("../browser/webapi/CData.zig"); -const Element = @import("../browser/webapi/Element.zig"); -const Node = @import("../browser/webapi/Node.zig"); -const AXNode = @import("AXNode.zig"); -const CDPNode = @import("Node.zig"); +const CData = @import("browser/webapi/CData.zig"); +const Element = @import("browser/webapi/Element.zig"); +const Node = @import("browser/webapi/Node.zig"); +const AXNode = @import("cdp/AXNode.zig"); +const CDPNode = @import("cdp/Node.zig"); -pub fn dump(root: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, page: *Page, arena: std.mem.Allocator) !void { - try dumpNode(root, registry, jw, page, "", arena); +const SemanticTree = @This(); + +dom_node: *Node, +registry: *CDPNode.Registry, +page: *Page, +arena: std.mem.Allocator, + +pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { + self.dumpNode(self.dom_node, jw, "") catch |err| { + log.err(.cdp, "semantic tree dump failed", .{ .err = err }); + return error.WriteFailed; + }; } -fn isAllWhitespace(text: []const u8) bool { +fn isAllWhitespace(_: @This(), text: []const u8) bool { for (text) |c| { if (!std.ascii.isWhitespace(c)) return false; } return true; } -fn getXPathSegment(node: *Node, arena: std.mem.Allocator) ![]const u8 { +fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); var index: usize = 1; @@ -54,7 +65,7 @@ fn getXPathSegment(node: *Node, arena: std.mem.Allocator) ![]const u8 { } } } - return std.fmt.allocPrint(arena, "/{s}[{d}]", .{ tag, index }); + return std.fmt.allocPrint(self.arena, "/{s}[{d}]", .{ tag, index }); } else if (node.is(CData.Text) != null) { var index: usize = 1; if (node._parent) |parent| { @@ -66,12 +77,12 @@ fn getXPathSegment(node: *Node, arena: std.mem.Allocator) ![]const u8 { } } } - return std.fmt.allocPrint(arena, "/text()[{d}]", .{index}); + return std.fmt.allocPrint(self.arena, "/text()[{d}]", .{index}); } return ""; } -fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, page: *Page, parent_xpath: []const u8, arena: std.mem.Allocator) !void { +fn dumpNode(self: @This(), node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -102,14 +113,14 @@ fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, p } else if (node.is(CData.Text) != null) { const text_node = node.is(CData.Text).?; const text = text_node.getWholeText(); - if (isAllWhitespace(text)) { + if (self.isAllWhitespace(text)) { return; } } else if (node._type != .document and node._type != .document_fragment) { return; } - const cdp_node = try registry.register(node); + const cdp_node = try self.registry.register(node); const axn = AXNode.fromNode(node); const role = try axn.getRole(); @@ -136,23 +147,23 @@ fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, p } const event_target = node.asEventTarget(); - if (page._event_manager.hasListener(event_target, "click") or - page._event_manager.hasListener(event_target, "mousedown") or - page._event_manager.hasListener(event_target, "mouseup") or - page._event_manager.hasListener(event_target, "keydown") or - page._event_manager.hasListener(event_target, "change") or - page._event_manager.hasListener(event_target, "input")) + if (self.page._event_manager.hasListener(event_target, "click") or + self.page._event_manager.hasListener(event_target, "mousedown") or + self.page._event_manager.hasListener(event_target, "mouseup") or + self.page._event_manager.hasListener(event_target, "keydown") or + self.page._event_manager.hasListener(event_target, "change") or + self.page._event_manager.hasListener(event_target, "input")) { is_interactive = true; } if (el.is(Element.Html)) |html_el| { - if (html_el.hasAttributeFunction(.onclick, page) or - html_el.hasAttributeFunction(.onmousedown, page) or - html_el.hasAttributeFunction(.onmouseup, page) or - html_el.hasAttributeFunction(.onkeydown, page) or - html_el.hasAttributeFunction(.onchange, page) or - html_el.hasAttributeFunction(.oninput, page)) + if (html_el.hasAttributeFunction(.onclick, self.page) or + html_el.hasAttributeFunction(.onmousedown, self.page) or + html_el.hasAttributeFunction(.onmouseup, self.page) or + html_el.hasAttributeFunction(.onkeydown, self.page) or + html_el.hasAttributeFunction(.onchange, self.page) or + html_el.hasAttributeFunction(.oninput, self.page)) { is_interactive = true; } @@ -161,8 +172,8 @@ fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, p node_name = "root"; } - const segment = try getXPathSegment(node, arena); - const xpath = try std.mem.concat(arena, u8, &.{ parent_xpath, segment }); + const segment = try self.getXPathSegment(node); + const xpath = try std.mem.concat(self.arena, u8, &.{ parent_xpath, segment }); try jw.beginObject(); @@ -213,7 +224,7 @@ fn dumpNode(node: *Node, registry: *CDPNode.Registry, jw: *std.json.Stringify, p try jw.beginArray(); var it = node.childrenIterator(); while (it.next()) |child| { - try dumpNode(child, registry, jw, page, xpath, arena); + try self.dumpNode(child, jw, xpath); } try jw.endArray(); diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index a5fcffe6..12dcbb7b 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -20,9 +20,9 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = @import("../../log.zig"); const markdown = lp.markdown; +const SemanticTree = lp.SemanticTree; const Node = @import("../Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); -const SemanticTree = @import("../semantic_tree.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -36,27 +36,13 @@ pub fn processMessage(cmd: anytype) !void { } } -const SemanticTreeResult = struct { - dom_node: *DOMNode, - registry: *Node.Registry, - page: *lp.Page, - arena: std.mem.Allocator, - - pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { - SemanticTree.dump(self.dom_node, self.registry, jw, self.page, self.arena) catch |err| { - log.err(.cdp, "semantic tree dump failed", .{ .err = err }); - return error.WriteFailed; - }; - } -}; - fn getSemanticTree(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.NoBrowserContext; const page = bc.session.currentPage() orelse return error.PageNotLoaded; const dom_node = page.document.asNode(); return cmd.sendResult(.{ - .semanticTree = .{ + .semanticTree = SemanticTree{ .dom_node = dom_node, .registry = &bc.node_registry, .page = page, diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b1bbed59..e506e61b 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -31,6 +31,7 @@ pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); pub const markdown = @import("browser/markdown.zig"); +pub const SemanticTree = @import("SemanticTree.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig index ef51f30c..3d56a37d 100644 --- a/src/mcp/Server.zig +++ b/src/mcp/Server.zig @@ -7,6 +7,7 @@ const HttpClient = @import("../http/Client.zig"); const testing = @import("../testing.zig"); const protocol = @import("protocol.zig"); const router = @import("router.zig"); +const CDPNode = @import("../cdp/Node.zig"); const Self = @This(); @@ -18,6 +19,7 @@ notification: *lp.Notification, browser: lp.Browser, session: *lp.Session, page: *lp.Page, +node_registry: CDPNode.Registry, writer: *std.io.Writer, mutex: std.Thread.Mutex = .{}, @@ -46,6 +48,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S .notification = notification, .session = undefined, .page = undefined, + .node_registry = CDPNode.Registry.init(allocator), }; self.session = try self.browser.newSession(self.notification); @@ -55,6 +58,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.io.Writer) !*S } pub fn deinit(self: *Self) void { + self.node_registry.deinit(); self.aw.deinit(); self.browser.deinit(); self.notification.deinit(); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 146bd7db..59ddf1b5 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -61,6 +61,18 @@ pub const tool_list = [_]protocol.Tool{ \\} ), }, + .{ + .name = "semantic_tree", + .description = "Get the page content as a simplified semantic DOM tree for AI reasoning. If a url is provided, it navigates to that url first.", + .inputSchema = protocol.minify( + \\{ + \\ "type": "object", + \\ "properties": { + \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." } + \\ } + \\} + ), + }, }; pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -79,18 +91,26 @@ const EvaluateParams = struct { const ToolStreamingText = struct { server: *Server, - action: enum { markdown, links }, + action: enum { markdown, links, semantic_tree }, + arena: std.mem.Allocator, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { - try jw.beginWriteRaw(); - try jw.writer.writeByte('"'); - var escaped = protocol.JsonEscapingWriter.init(jw.writer); - const w = &escaped.writer; switch (self.action) { - .markdown => lp.markdown.dump(self.server.page.document.asNode(), .{}, w, self.server.page) catch |err| { - log.err(.mcp, "markdown dump failed", .{ .err = err }); + .markdown => { + try jw.beginWriteRaw(); + try jw.writer.writeByte('"'); + var escaped = protocol.JsonEscapingWriter.init(jw.writer); + lp.markdown.dump(self.server.page.document.asNode(), .{}, &escaped.writer, self.server.page) catch |err| { + log.err(.mcp, "markdown dump failed", .{ .err = err }); + }; + try jw.writer.writeByte('"'); + jw.endWriteRaw(); }, .links => { + try jw.beginWriteRaw(); + try jw.writer.writeByte('"'); + var escaped = protocol.JsonEscapingWriter.init(jw.writer); + const w = &escaped.writer; if (Selector.querySelectorAll(self.server.page.document.asNode(), "a[href]", self.server.page)) |list| { defer list.deinit(self.server.page); var first = true; @@ -111,10 +131,30 @@ const ToolStreamingText = struct { } else |err| { log.err(.mcp, "query links failed", .{ .err = err }); } + try jw.writer.writeByte('"'); + jw.endWriteRaw(); + }, + .semantic_tree => { + // MCP expects a string for "text" content, but our SemanticTree is a complex object. + // We'll serialize it as a string to fit the MCP text protocol requirements. + try jw.beginWriteRaw(); + try jw.writer.writeByte('"'); + var escaped = protocol.JsonEscapingWriter.init(jw.writer); + + const st = lp.SemanticTree{ + .dom_node = self.server.page.document.asNode(), + .registry = &self.server.node_registry, + .page = self.server.page, + .arena = self.arena, + }; + std.json.Stringify.value(st, .{ .whitespace = .minified }, &escaped.writer) catch |err| { + log.err(.mcp, "semantic tree dump failed", .{ .err = err }); + }; + + try jw.writer.writeByte('"'); + jw.endWriteRaw(); }, } - try jw.writer.writeByte('"'); - jw.endWriteRaw(); } }; @@ -124,6 +164,7 @@ const ToolAction = enum { markdown, links, evaluate, + semantic_tree, }; const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ @@ -132,6 +173,7 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ .{ "markdown", .markdown }, .{ "links", .links }, .{ "evaluate", .evaluate }, + .{ "semantic_tree", .semantic_tree }, }); pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { @@ -157,6 +199,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque .markdown => try handleMarkdown(server, arena, req.id.?, call_params.arguments), .links => try handleLinks(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), + .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), } } @@ -181,7 +224,7 @@ fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, } const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .server = server, .action = .markdown }, + .text = .{ .server = server, .action = .markdown, .arena = arena }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } @@ -199,7 +242,25 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar } const content = [_]protocol.TextContent(ToolStreamingText){.{ - .text = .{ .server = server, .action = .links }, + .text = .{ .server = server, .action = .links, .arena = arena }, + }}; + try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); +} + +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, + }; + if (arguments) |args_raw| { + if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { + if (args.url) |u| { + try performGoto(server, u, id); + } + } else |_| {} + } + + const content = [_]protocol.TextContent(ToolStreamingText){.{ + .text = .{ .server = server, .action = .semantic_tree, .arena = arena }, }}; try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); } From 471ba5baf63f28baf819bc7e54e882d2f0bb1103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 15:52:26 +0900 Subject: [PATCH 03/32] String: refactor isAllWhitespace into String --- src/SemanticTree.zig | 14 ++++---------- src/browser/markdown.zig | 7 +------ src/string.zig | 6 ++++++ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index cf76acec..4f80addd 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = @import("log.zig"); +const isAllWhitespace = @import("string.zig").isAllWhitespace; const Page = lp.Page; const CData = @import("browser/webapi/CData.zig"); @@ -28,7 +29,7 @@ const Node = @import("browser/webapi/Node.zig"); const AXNode = @import("cdp/AXNode.zig"); const CDPNode = @import("cdp/Node.zig"); -const SemanticTree = @This(); +const Self = @This(); dom_node: *Node, registry: *CDPNode.Registry, @@ -42,13 +43,6 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! }; } -fn isAllWhitespace(_: @This(), text: []const u8) bool { - for (text) |c| { - if (!std.ascii.isWhitespace(c)) return false; - } - return true; -} - fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -82,7 +76,7 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { return ""; } -fn dumpNode(self: @This(), node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { +fn dumpNode(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -113,7 +107,7 @@ fn dumpNode(self: @This(), node: *Node, jw: *std.json.Stringify, parent_xpath: [ } else if (node.is(CData.Text) != null) { const text_node = node.is(CData.Text).?; const text = text_node.getWholeText(); - if (self.isAllWhitespace(text)) { + if (isAllWhitespace(text)) { return; } } else if (node._type != .document and node._type != .document_fragment) { diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index f0ccd56e..af298b8f 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -24,6 +24,7 @@ const TreeWalker = @import("webapi/TreeWalker.zig"); const CData = @import("webapi/CData.zig"); const Element = @import("webapi/Element.zig"); const Node = @import("webapi/Node.zig"); +const isAllWhitespace = @import("../string.zig").isAllWhitespace; pub const Opts = struct { // Options for future customization (e.g., dialect) @@ -109,12 +110,6 @@ fn getAnchorLabel(el: *Element) ?[]const u8 { return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title")); } -fn isAllWhitespace(text: []const u8) bool { - return for (text) |c| { - if (!std.ascii.isWhitespace(c)) break false; - } else true; -} - fn hasBlockDescendant(root: *Node) bool { var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{}); while (tw.next()) |el| { diff --git a/src/string.zig b/src/string.zig index 8cb15c8f..d00ec33b 100644 --- a/src/string.zig +++ b/src/string.zig @@ -305,6 +305,12 @@ pub const String = packed struct { } }; +pub fn isAllWhitespace(text: []const u8) bool { + return for (text) |c| { + if (!std.ascii.isWhitespace(c)) break false; + } else true; +} + // Discriminatory type that signals the bridge to use arena instead of call_arena // Use this for strings that need to persist beyond the current call // The caller can unwrap and store just the underlying .str field From f2832447d471a69b421cb9553477f03fab5f6d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 16:12:57 +0900 Subject: [PATCH 04/32] SemanticTree: optimize tag and role filtering * Refactored tag ignoring logic to use the el.getTag() enum switch instead of string comparisons, improving performance and safety. * Replaced string comparisons for interactive roles with std.StaticStringMap. * Renamed internal dumpNode method to dump for brevity. --- src/SemanticTree.zig | 47 ++++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 4f80addd..815f22d9 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -31,13 +31,27 @@ const CDPNode = @import("cdp/Node.zig"); const Self = @This(); +const interactive_roles = std.StaticStringMap(void).initComptime(.{ + .{ "button", {} }, + .{ "link", {} }, + .{ "checkbox", {} }, + .{ "radio", {} }, + .{ "textbox", {} }, + .{ "combobox", {} }, + .{ "searchbox", {} }, + .{ "slider", {} }, + .{ "spinbutton", {} }, + .{ "switch", {} }, + .{ "menuitem", {} }, +}); + dom_node: *Node, registry: *CDPNode.Registry, page: *Page, arena: std.mem.Allocator, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { - self.dumpNode(self.dom_node, jw, "") catch |err| { + self.dump(self.dom_node, jw, "") catch |err| { log.err(.cdp, "semantic tree dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -76,20 +90,12 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { return ""; } -fn dumpNode(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { +fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { - const tag = el.getTagNameLower(); - if (std.mem.eql(u8, tag, "script") or - std.mem.eql(u8, tag, "style") or - std.mem.eql(u8, tag, "meta") or - std.mem.eql(u8, tag, "link") or - std.mem.eql(u8, tag, "noscript") or - std.mem.eql(u8, tag, "svg") or - std.mem.eql(u8, tag, "head") or - std.mem.eql(u8, tag, "title")) - { - return; + switch (el.getTag()) { + .script, .style, .meta, .link, .noscript, .svg, .head, .title => return, + else => {}, } // CSS display: none visibility check (inline style only for now) @@ -125,18 +131,7 @@ fn dumpNode(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []co if (node.is(Element)) |el| { node_name = el.getTagNameLower(); - if (std.mem.eql(u8, role, "button") or - std.mem.eql(u8, role, "link") or - std.mem.eql(u8, role, "checkbox") or - std.mem.eql(u8, role, "radio") or - std.mem.eql(u8, role, "textbox") or - std.mem.eql(u8, role, "combobox") or - std.mem.eql(u8, role, "searchbox") or - std.mem.eql(u8, role, "slider") or - std.mem.eql(u8, role, "spinbutton") or - std.mem.eql(u8, role, "switch") or - std.mem.eql(u8, role, "menuitem")) - { + if (interactive_roles.has(role)) { is_interactive = true; } @@ -218,7 +213,7 @@ fn dumpNode(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []co try jw.beginArray(); var it = node.childrenIterator(); while (it.next()) |child| { - try self.dumpNode(child, jw, xpath); + try self.dump(child, jw, xpath); } try jw.endArray(); From e0f0b9f210d029e5189eb8116de3912a316494ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 16:24:49 +0900 Subject: [PATCH 05/32] SemanticTree: use AXRole enum for interactive role check --- src/SemanticTree.zig | 17 ++--------------- src/cdp/AXNode.zig | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 815f22d9..ce509d97 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -31,20 +31,6 @@ const CDPNode = @import("cdp/Node.zig"); const Self = @This(); -const interactive_roles = std.StaticStringMap(void).initComptime(.{ - .{ "button", {} }, - .{ "link", {} }, - .{ "checkbox", {} }, - .{ "radio", {} }, - .{ "textbox", {} }, - .{ "combobox", {} }, - .{ "searchbox", {} }, - .{ "slider", {} }, - .{ "spinbutton", {} }, - .{ "switch", {} }, - .{ "menuitem", {} }, -}); - dom_node: *Node, registry: *CDPNode.Registry, page: *Page, @@ -131,7 +117,8 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const if (node.is(Element)) |el| { node_name = el.getTagNameLower(); - if (interactive_roles.has(role)) { + const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; + if (ax_role.isInteractive()) { is_interactive = true; } diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index d7c68db2..27272de0 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -560,13 +560,31 @@ pub const AXRole = enum(u8) { none, article, banner, blockquote, button, caption, cell, checkbox, code, columnheader, combobox, complementary, contentinfo, definition, deletion, dialog, document, emphasis, figure, form, group, heading, image, insertion, - link, list, listbox, listitem, main, marquee, meter, navigation, option, + link, list, listbox, listitem, main, marquee, menuitem, meter, navigation, option, paragraph, presentation, progressbar, radio, region, row, rowgroup, rowheader, searchbox, separator, slider, spinbutton, status, strong, - subscript, superscript, table, term, textbox, time, RootWebArea, LineBreak, + subscript, superscript, @"switch", table, term, textbox, time, RootWebArea, LineBreak, StaticText, // zig fmt: on + pub fn isInteractive(self: AXRole) bool { + return switch (self) { + .button, + .link, + .checkbox, + .radio, + .textbox, + .combobox, + .searchbox, + .slider, + .spinbutton, + .@"switch", + .menuitem, + => true, + else => false, + }; + } + fn fromNode(node: *DOMNode) !AXRole { return switch (node._type) { .document => return .RootWebArea, // Chrome specific. From 45705a3e2914737238993f72e0784731d89f36ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 6 Mar 2026 16:31:28 +0900 Subject: [PATCH 06/32] webapi: move tag category logic to Tag enum --- src/SemanticTree.zig | 10 +++----- src/browser/markdown.zig | 19 ++++----------- src/browser/webapi/Element.zig | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index ce509d97..43b50db2 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -79,10 +79,8 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { - switch (el.getTag()) { - .script, .style, .meta, .link, .noscript, .svg, .head, .title => return, - else => {}, - } + const tag = el.getTag(); + if (tag.isMetadata() or tag == .svg) return; // CSS display: none visibility check (inline style only for now) if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { @@ -118,9 +116,7 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const node_name = el.getTagNameLower(); const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; - if (ax_role.isInteractive()) { - is_interactive = true; - } + is_interactive = ax_role.isInteractive(); const event_target = node.asEventTarget(); if (self.page._event_manager.hasListener(event_target, "click") or diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index af298b8f..8a4984a4 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -47,13 +47,6 @@ const State = struct { last_char_was_newline: bool = true, }; -fn isBlock(tag: Element.Tag) bool { - return switch (tag) { - .p, .div, .section, .article, .main, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true, - else => false, - }; -} - fn shouldAddSpacing(tag: Element.Tag) bool { return switch (tag) { .p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true, @@ -100,10 +93,8 @@ fn isSignificantText(node: *Node) bool { } fn isVisibleElement(el: *Element) bool { - return switch (el.getTag()) { - .script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => false, - else => true, - }; + const tag = el.getTag(); + return !tag.isMetadata() and tag != .svg; } fn getAnchorLabel(el: *Element) ?[]const u8 { @@ -113,7 +104,7 @@ fn getAnchorLabel(el: *Element) ?[]const u8 { fn hasBlockDescendant(root: *Node) bool { var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{}); while (tw.next()) |el| { - if (isBlock(el.getTag())) return true; + if (el.getTag().isBlock()) return true; } return false; } @@ -187,7 +178,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag // --- Opening Tag Logic --- // Ensure block elements start on a new line (double newline for paragraphs etc) - if (isBlock(tag) and !state.in_table) { + if (tag.isBlock() and !state.in_table) { try ensureNewline(state, writer); if (shouldAddSpacing(tag)) { try writer.writeByte('\n'); @@ -426,7 +417,7 @@ fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Pag } // Post-block newlines - if (isBlock(tag) and !state.in_table) { + if (tag.isBlock() and !state.in_table) { try ensureNewline(state, writer); } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index ef2386da..b4fedc9f 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1588,6 +1588,50 @@ pub const Tag = enum { else => tag, }; } + + pub fn isBlock(self: Tag) bool { + return switch (self) { + .p, + .div, + .section, + .article, + .main, + .header, + .footer, + .nav, + .aside, + .h1, + .h2, + .h3, + .h4, + .h5, + .h6, + .ul, + .ol, + .blockquote, + .pre, + .table, + .hr, + => true, + else => false, + }; + } + + pub fn isMetadata(self: Tag) bool { + return switch (self) { + .script, + .style, + .meta, + .link, + .noscript, + .head, + .title, + .base, + .template, + => true, + else => false, + }; + } }; pub const JsApi = struct { From b8139a6e83263de13c2dd4e942452aeb17f876e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 8 Mar 2026 15:48:44 +0900 Subject: [PATCH 07/32] CDP/MCP: improve Stagehand compatibility for semantic tree --- src/SemanticTree.zig | 24 ++++++++++++++-- src/cdp/AXNode.zig | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 43b50db2..60df9fe7 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -150,9 +150,9 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const try jw.beginObject(); try jw.objectField("nodeId"); - try jw.write(cdp_node.id); + try jw.write(try std.fmt.allocPrint(self.arena, "{d}", .{cdp_node.id})); - try jw.objectField("backendNodeId"); + try jw.objectField("backendDOMNodeId"); try jw.write(cdp_node.id); try jw.objectField("nodeName"); @@ -171,6 +171,26 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const try jw.objectField("role"); try jw.write(role); + // Add accessible name (e.g. button label, aria-label, etc.) + if (try axn.getName(self.page, self.arena)) |name| { + if (name.len > 0) { + try jw.objectField("name"); + try jw.write(name); + } + } + + // Add value for input elements + if (el.is(Element.Html.Input)) |input| { + try jw.objectField("value"); + try jw.write(input.getValue()); + } else if (el.is(Element.Html.TextArea)) |textarea| { + try jw.objectField("value"); + try jw.write(textarea.getValue()); + } else if (el.is(Element.Html.Select)) |select| { + try jw.objectField("value"); + try jw.write(select.getValue(self.page)); + } + if (el._attributes) |attrs| { try jw.objectField("attributes"); try jw.beginObject(); diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 27272de0..c74a02fc 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -756,6 +756,74 @@ const AXSource = enum(u8) { value, // input value }; +pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]const u8 { + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + // We need to bypass the strict JSON writer used in writeName + // We'll create a dummy writer that just writes to our buffer + const DummyWriter = struct { + aw: *std.Io.Writer.Allocating, + writer: *std.Io.Writer, + + pub fn write(w: @This(), val: anytype) !void { + const T = @TypeOf(val); + if (T == []const u8 or T == [:0]const u8 or T == *const [val.len]u8) { + try w.aw.writer.writeAll(val); + } else if (comptime std.meta.hasMethod(T, "format")) { + try std.fmt.format(w.aw.writer, "{s}", .{val}); + } else { + // Ignore unexpected types to avoid garbage output + } + } + + pub fn beginWriteRaw(w: @This()) !void { + _ = w; + } + pub fn endWriteRaw(w: @This()) void { + _ = w; + } + pub fn writeByte(w: @This(), b: u8) !void { + try w.aw.writer.writeByte(b); + } + pub fn writeAll(w: @This(), s: []const u8) !void { + try w.aw.writer.writeAll(s); + } + + // Mock object methods + pub fn objectField(w: @This(), name: []const u8) !void { + _ = w; + _ = name; + } + pub fn beginObject(w: @This()) !void { + _ = w; + } + pub fn endObject(w: @This()) !void { + _ = w; + } + pub fn beginArray(w: @This()) !void { + _ = w; + } + pub fn endArray(w: @This()) !void { + _ = w; + } + }; + + const w = DummyWriter{ .aw = &aw, .writer = &aw.writer }; + + const source = try self.writeName(w, page); + if (source != null) { + var str = aw.written(); + // writeString manually injects literal quotes for JSON, we need to strip them + if (str.len >= 2 and str[0] == '"' and str[str.len - 1] == '"') { + str = str[1 .. str.len - 1]; + } + return try allocator.dupe(u8, str); + } + + return null; +} + fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource { const node = axnode.dom; From b674c2e448590f69ed7d4c4a04f007cef0f6abda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 8 Mar 2026 22:41:51 +0900 Subject: [PATCH 08/32] CDP/MCP: add highly compressed text format for semantic tree --- src/SemanticTree.zig | 110 +++++++++++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 31 +++++++++--- src/mcp/tools.zig | 6 +-- 3 files changed, 138 insertions(+), 9 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 60df9fe7..2bd642da 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -43,6 +43,13 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! }; } +pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { + self.dumpText(self.dom_node, writer, 0) catch |err| { + log.err(.cdp, "semantic tree text dump failed", .{ .err = err }); + return error.WriteFailed; + }; +} + fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -222,3 +229,106 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const try jw.endObject(); } + +fn dumpText(self: Self, node: *Node, writer: *std.Io.Writer, depth: usize) !void { + // 1. Skip non-content nodes + if (node.is(Element)) |el| { + const tag = el.getTag(); + if (tag.isMetadata() or tag == .svg) return; + + // CSS display: none visibility check (inline style only for now) + if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { + if (std.mem.indexOf(u8, style, "display: none") != null or + std.mem.indexOf(u8, style, "display:none") != null) + { + return; + } + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.getHidden()) return; + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + const text = text_node.getWholeText(); + if (isAllWhitespace(text)) { + return; + } + } else if (node._type != .document and node._type != .document_fragment) { + return; + } + + const cdp_node = try self.registry.register(node); + const axn = AXNode.fromNode(node); + const role = try axn.getRole(); + + var is_interactive = false; + var value: ?[]const u8 = null; + + if (node.is(Element)) |el| { + const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; + is_interactive = ax_role.isInteractive(); + + const event_target = node.asEventTarget(); + if (self.page._event_manager.hasListener(event_target, "click") or + self.page._event_manager.hasListener(event_target, "mousedown") or + self.page._event_manager.hasListener(event_target, "mouseup") or + self.page._event_manager.hasListener(event_target, "keydown") or + self.page._event_manager.hasListener(event_target, "change") or + self.page._event_manager.hasListener(event_target, "input")) + { + is_interactive = true; + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.hasAttributeFunction(.onclick, self.page) or + html_el.hasAttributeFunction(.onmousedown, self.page) or + html_el.hasAttributeFunction(.onmouseup, self.page) or + html_el.hasAttributeFunction(.onkeydown, self.page) or + html_el.hasAttributeFunction(.onchange, self.page) or + html_el.hasAttributeFunction(.oninput, self.page)) + { + is_interactive = true; + } + } + + if (el.is(Element.Html.Input)) |input| { + value = input.getValue(); + } else if (el.is(Element.Html.TextArea)) |textarea| { + value = textarea.getValue(); + } else if (el.is(Element.Html.Select)) |select| { + value = select.getValue(self.page); + } + } + + // Format: " [12] link: Hacker News (value)" + for (0..(depth * 2)) |_| { + try writer.writeByte(' '); + } + try writer.print("[{d}] {s}: ", .{ cdp_node.id, role }); + + if (try axn.getName(self.page, self.arena)) |name| { + if (name.len > 0) { + try writer.writeAll(name); + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); + if (trimmed.len > 0) { + try writer.writeAll(trimmed); + } + } + + if (value) |v| { + if (v.len > 0) { + try writer.print(" (value: {s})", .{v}); + } + } + + try writer.writeByte('\n'); + + var it = node.childrenIterator(); + while (it.next()) |child| { + try self.dumpText(child, writer, depth + 1); + } +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 12dcbb7b..2470a312 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -37,17 +37,36 @@ pub fn processMessage(cmd: anytype) !void { } fn getSemanticTree(cmd: anytype) !void { + const Params = struct { + format: ?[]const u8 = 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 st = SemanticTree{ + .dom_node = dom_node, + .registry = &bc.node_registry, + .page = page, + .arena = cmd.arena, + }; + + if (params.format) |format| { + if (std.mem.eql(u8, format, "text")) { + var aw: std.Io.Writer.Allocating = .init(cmd.arena); + defer aw.deinit(); + try st.textStringify(&aw.writer); + + return cmd.sendResult(.{ + .semanticTree = aw.written(), + }, .{}); + } + } + return cmd.sendResult(.{ - .semanticTree = SemanticTree{ - .dom_node = dom_node, - .registry = &bc.node_registry, - .page = page, - .arena = cmd.arena, - }, + .semanticTree = st, }, .{}); } diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 59ddf1b5..8ab55f48 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -135,8 +135,7 @@ const ToolStreamingText = struct { jw.endWriteRaw(); }, .semantic_tree => { - // MCP expects a string for "text" content, but our SemanticTree is a complex object. - // We'll serialize it as a string to fit the MCP text protocol requirements. + // Return the highly compressed Stagehand-style text format for maximum token efficiency try jw.beginWriteRaw(); try jw.writer.writeByte('"'); var escaped = protocol.JsonEscapingWriter.init(jw.writer); @@ -147,7 +146,8 @@ const ToolStreamingText = struct { .page = self.server.page, .arena = self.arena, }; - std.json.Stringify.value(st, .{ .whitespace = .minified }, &escaped.writer) catch |err| { + + st.textStringify(&escaped.writer) catch |err| { log.err(.mcp, "semantic tree dump failed", .{ .err = err }); }; From 4ba40f22955d73bf80dce1b6fd448e364c590b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Sun, 8 Mar 2026 22:48:22 +0900 Subject: [PATCH 09/32] CDP: implement intelligent pruning for textified semantic tree output --- src/SemanticTree.zig | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 2bd642da..bcd915df 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -230,6 +230,12 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const try jw.endObject(); } +fn isStructuralRole(role: []const u8) bool { + return std.mem.eql(u8, role, "none") or + std.mem.eql(u8, role, "generic") or + std.mem.eql(u8, role, "InlineTextBox"); +} + fn dumpText(self: Self, node: *Node, writer: *std.Io.Writer, depth: usize) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { @@ -301,15 +307,44 @@ fn dumpText(self: Self, node: *Node, writer: *std.Io.Writer, depth: usize) !void } } + const name = try axn.getName(self.page, self.arena); + + // Pruning Heuristic: + // If it's a structural node (none/generic) and has no unique label, unwrap it. + // We only keep 'none'/'generic' if they are interactive. + const structural = isStructuralRole(role); + const has_explicit_label = if (node.is(Element)) |el| + el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null + else + false; + + if (structural and !is_interactive and !has_explicit_label) { + // Just unwrap and process children + var it = node.childrenIterator(); + while (it.next()) |child| { + try self.dumpText(child, writer, depth); + } + return; + } + + // Skip redundant StaticText nodes if the parent already captures the text + if (std.mem.eql(u8, role, "StaticText") and node._parent != null) { + const parent_axn = AXNode.fromNode(node._parent.?); + const parent_name = try parent_axn.getName(self.page, self.arena); + if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) { + return; + } + } + // Format: " [12] link: Hacker News (value)" for (0..(depth * 2)) |_| { try writer.writeByte(' '); } try writer.print("[{d}] {s}: ", .{ cdp_node.id, role }); - if (try axn.getName(self.page, self.arena)) |name| { - if (name.len > 0) { - try writer.writeAll(name); + if (name) |n| { + if (n.len > 0) { + try writer.writeAll(n); } } else if (node.is(CData.Text) != null) { const text_node = node.is(CData.Text).?; From be73c1439560fc5cd4881cfaedbc23a41eda7094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 10:29:32 +0900 Subject: [PATCH 10/32] SemanticTree: rename dump to dumpJson and update log tags --- src/SemanticTree.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index bcd915df..65490a2b 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -37,15 +37,15 @@ page: *Page, arena: std.mem.Allocator, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { - self.dump(self.dom_node, jw, "") catch |err| { - log.err(.cdp, "semantic tree dump failed", .{ .err = err }); + self.dumpJson(self.dom_node, jw, "") catch |err| { + log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; } pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { self.dumpText(self.dom_node, writer, 0) catch |err| { - log.err(.cdp, "semantic tree text dump failed", .{ .err = err }); + log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; } @@ -83,7 +83,7 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { return ""; } -fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { +fn dumpJson(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -223,7 +223,7 @@ fn dump(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const try jw.beginArray(); var it = node.childrenIterator(); while (it.next()) |child| { - try self.dump(child, jw, xpath); + try self.dumpJson(child, jw, xpath); } try jw.endArray(); From 2a2b0676330e1420071bebb3465de69c32057a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 10:37:21 +0900 Subject: [PATCH 11/32] mcp: fix wrong merge --- src/mcp/tools.zig | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index c3f03833..ebdd0408 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -97,22 +97,16 @@ const ToolStreamingText = struct { arena: ?std.mem.Allocator = null, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { + try jw.beginWriteRaw(); + try jw.writer.writeByte('"'); + var escaped: protocol.JsonEscapingWriter = .init(jw.writer); + const w = &escaped.writer; + switch (self.action) { - .markdown => { - try jw.beginWriteRaw(); - try jw.writer.writeByte('"'); - var escaped: protocol.JsonEscapingWriter = .init(jw.writer); - lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| { - log.err(.mcp, "markdown dump failed", .{ .err = err }); - }; - try jw.writer.writeByte('"'); - jw.endWriteRaw(); + .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| { + log.err(.mcp, "markdown dump failed", .{ .err = err }); }, .links => { - try jw.beginWriteRaw(); - try jw.writer.writeByte('"'); - var escaped: protocol.JsonEscapingWriter = .init(jw.writer); - const w = &escaped.writer; if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { defer list.deinit(self.page); var first = true; @@ -133,15 +127,8 @@ const ToolStreamingText = struct { } else |err| { log.err(.mcp, "query links failed", .{ .err = err }); } - try jw.writer.writeByte('"'); - jw.endWriteRaw(); }, .semantic_tree => { - // Return the highly compressed Stagehand-style text format for maximum token efficiency - try jw.beginWriteRaw(); - try jw.writer.writeByte('"'); - var escaped: protocol.JsonEscapingWriter = .init(jw.writer); - const st = lp.SemanticTree{ .dom_node = self.page.document.asNode(), .registry = self.registry.?, @@ -149,14 +136,14 @@ const ToolStreamingText = struct { .arena = self.arena.?, }; - st.textStringify(&escaped.writer) catch |err| { + st.textStringify(w) catch |err| { log.err(.mcp, "semantic tree dump failed", .{ .err = err }); }; - - try jw.writer.writeByte('"'); - jw.endWriteRaw(); }, } + + try jw.writer.writeByte('"'); + jw.endWriteRaw(); } }; From d80e9260152707feb3290cee1c6bf40c413dcc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 11:09:27 +0900 Subject: [PATCH 12/32] SemanticTree: unify tree traversal using visitor pattern --- src/SemanticTree.zig | 494 +++++++++++++++++++++---------------------- 1 file changed, 237 insertions(+), 257 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 65490a2b..03b48ec4 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -14,7 +14,7 @@ // 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 . +// along with this program. See . const std = @import("std"); @@ -37,19 +37,133 @@ page: *Page, arena: std.mem.Allocator, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { - self.dumpJson(self.dom_node, jw, "") catch |err| { + var visitor = JsonVisitor{ .jw = jw, .tree = self }; + self.walk(self.dom_node, "", &visitor) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; } pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { - self.dumpText(self.dom_node, writer, 0) catch |err| { + var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; + self.walk(self.dom_node, "", &visitor) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; } +const NodeData = struct { + id: u32, + axn: AXNode, + role: []const u8, + name: ?[]const u8, + value: ?[]const u8, + xpath: []const u8, + is_interactive: bool, + node_name: []const u8, +}; + +fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) !void { + // 1. Skip non-content nodes + if (node.is(Element)) |el| { + const tag = el.getTag(); + if (tag.isMetadata() or tag == .svg) return; + + // CSS display: none visibility check (inline style only for now) + if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { + if (std.mem.indexOf(u8, style, "display: none") != null or + std.mem.indexOf(u8, style, "display:none") != null) + { + return; + } + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.getHidden()) return; + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + const text = text_node.getWholeText(); + if (isAllWhitespace(text)) { + return; + } + } else if (node._type != .document and node._type != .document_fragment) { + return; + } + + const cdp_node = try self.registry.register(node); + const axn = AXNode.fromNode(node); + const role = try axn.getRole(); + + var is_interactive = false; + var value: ?[]const u8 = null; + var node_name: []const u8 = "text"; + + if (node.is(Element)) |el| { + node_name = el.getTagNameLower(); + + const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; + is_interactive = ax_role.isInteractive(); + + const event_target = node.asEventTarget(); + if (self.page._event_manager.hasListener(event_target, "click") or + self.page._event_manager.hasListener(event_target, "mousedown") or + self.page._event_manager.hasListener(event_target, "mouseup") or + self.page._event_manager.hasListener(event_target, "keydown") or + self.page._event_manager.hasListener(event_target, "change") or + self.page._event_manager.hasListener(event_target, "input")) + { + is_interactive = true; + } + + if (el.is(Element.Html)) |html_el| { + if (html_el.hasAttributeFunction(.onclick, self.page) or + html_el.hasAttributeFunction(.onmousedown, self.page) or + html_el.hasAttributeFunction(.onmouseup, self.page) or + html_el.hasAttributeFunction(.onkeydown, self.page) or + html_el.hasAttributeFunction(.onchange, self.page) or + html_el.hasAttributeFunction(.oninput, self.page)) + { + is_interactive = true; + } + } + + if (el.is(Element.Html.Input)) |input| { + value = input.getValue(); + } else if (el.is(Element.Html.TextArea)) |textarea| { + value = textarea.getValue(); + } else if (el.is(Element.Html.Select)) |select| { + value = select.getValue(self.page); + } + } else if (node._type == .document or node._type == .document_fragment) { + node_name = "root"; + } + + const segment = try self.getXPathSegment(node); + const xpath = try std.mem.concat(self.arena, u8, &.{ parent_xpath, segment }); + + const name = try axn.getName(self.page, self.arena); + + var data = NodeData{ + .id = cdp_node.id, + .axn = axn, + .role = role, + .name = name, + .value = value, + .xpath = xpath, + .is_interactive = is_interactive, + .node_name = node_name, + }; + + if (try visitor.visit(node, &data)) { + var it = node.childrenIterator(); + while (it.next()) |child| { + try self.walk(child, xpath, visitor); + } + try visitor.leave(node, &data); + } +} + fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -83,152 +197,78 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { return ""; } -fn dumpJson(self: Self, node: *Node, jw: *std.json.Stringify, parent_xpath: []const u8) !void { - // 1. Skip non-content nodes - if (node.is(Element)) |el| { - const tag = el.getTag(); - if (tag.isMetadata() or tag == .svg) return; +const JsonVisitor = struct { + jw: *std.json.Stringify, + tree: Self, - // CSS display: none visibility check (inline style only for now) - if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { - if (std.mem.indexOf(u8, style, "display: none") != null or - std.mem.indexOf(u8, style, "display:none") != null) - { - return; + pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool { + try self.jw.beginObject(); + + try self.jw.objectField("nodeId"); + try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id})); + + try self.jw.objectField("backendDOMNodeId"); + try self.jw.write(data.id); + + try self.jw.objectField("nodeName"); + try self.jw.write(data.node_name); + + try self.jw.objectField("xpath"); + try self.jw.write(data.xpath); + + if (node.is(Element)) |el| { + try self.jw.objectField("nodeType"); + try self.jw.write(1); + + try self.jw.objectField("isInteractive"); + try self.jw.write(data.is_interactive); + + try self.jw.objectField("role"); + try self.jw.write(data.role); + + if (data.name) |name| { + if (name.len > 0) { + try self.jw.objectField("name"); + try self.jw.write(name); + } } + + if (data.value) |value| { + try self.jw.objectField("value"); + try self.jw.write(value); + } + + if (el._attributes) |attrs| { + try self.jw.objectField("attributes"); + try self.jw.beginObject(); + var iter = attrs.iterator(); + while (iter.next()) |attr| { + try self.jw.objectField(attr._name.str()); + try self.jw.write(attr._value.str()); + } + try self.jw.endObject(); + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + try self.jw.objectField("nodeType"); + try self.jw.write(3); + try self.jw.objectField("nodeValue"); + try self.jw.write(text_node.getWholeText()); + } else { + try self.jw.objectField("nodeType"); + try self.jw.write(9); } - if (el.is(Element.Html)) |html_el| { - if (html_el.getHidden()) return; - } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; - const text = text_node.getWholeText(); - if (isAllWhitespace(text)) { - return; - } - } else if (node._type != .document and node._type != .document_fragment) { - return; + try self.jw.objectField("children"); + try self.jw.beginArray(); + return true; } - const cdp_node = try self.registry.register(node); - const axn = AXNode.fromNode(node); - - const role = try axn.getRole(); - - var is_interactive = false; - var node_name: []const u8 = "text"; - - if (node.is(Element)) |el| { - node_name = el.getTagNameLower(); - - const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; - is_interactive = ax_role.isInteractive(); - - const event_target = node.asEventTarget(); - if (self.page._event_manager.hasListener(event_target, "click") or - self.page._event_manager.hasListener(event_target, "mousedown") or - self.page._event_manager.hasListener(event_target, "mouseup") or - self.page._event_manager.hasListener(event_target, "keydown") or - self.page._event_manager.hasListener(event_target, "change") or - self.page._event_manager.hasListener(event_target, "input")) - { - is_interactive = true; - } - - if (el.is(Element.Html)) |html_el| { - if (html_el.hasAttributeFunction(.onclick, self.page) or - html_el.hasAttributeFunction(.onmousedown, self.page) or - html_el.hasAttributeFunction(.onmouseup, self.page) or - html_el.hasAttributeFunction(.onkeydown, self.page) or - html_el.hasAttributeFunction(.onchange, self.page) or - html_el.hasAttributeFunction(.oninput, self.page)) - { - is_interactive = true; - } - } - } else if (node._type == .document or node._type == .document_fragment) { - node_name = "root"; + pub fn leave(self: *JsonVisitor, _: *Node, _: *NodeData) !void { + try self.jw.endArray(); + try self.jw.endObject(); } - - const segment = try self.getXPathSegment(node); - const xpath = try std.mem.concat(self.arena, u8, &.{ parent_xpath, segment }); - - try jw.beginObject(); - - try jw.objectField("nodeId"); - try jw.write(try std.fmt.allocPrint(self.arena, "{d}", .{cdp_node.id})); - - try jw.objectField("backendDOMNodeId"); - try jw.write(cdp_node.id); - - try jw.objectField("nodeName"); - try jw.write(node_name); - - try jw.objectField("xpath"); - try jw.write(xpath); - - if (node.is(Element)) |el| { - try jw.objectField("nodeType"); - try jw.write(1); - - try jw.objectField("isInteractive"); - try jw.write(is_interactive); - - try jw.objectField("role"); - try jw.write(role); - - // Add accessible name (e.g. button label, aria-label, etc.) - if (try axn.getName(self.page, self.arena)) |name| { - if (name.len > 0) { - try jw.objectField("name"); - try jw.write(name); - } - } - - // Add value for input elements - if (el.is(Element.Html.Input)) |input| { - try jw.objectField("value"); - try jw.write(input.getValue()); - } else if (el.is(Element.Html.TextArea)) |textarea| { - try jw.objectField("value"); - try jw.write(textarea.getValue()); - } else if (el.is(Element.Html.Select)) |select| { - try jw.objectField("value"); - try jw.write(select.getValue(self.page)); - } - - if (el._attributes) |attrs| { - try jw.objectField("attributes"); - try jw.beginObject(); - var iter = attrs.iterator(); - while (iter.next()) |attr| { - try jw.objectField(attr._name.str()); - try jw.write(attr._value.str()); - } - try jw.endObject(); - } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; - try jw.objectField("nodeType"); - try jw.write(3); - try jw.objectField("nodeValue"); - try jw.write(text_node.getWholeText()); - } else { - try jw.objectField("nodeType"); - try jw.write(9); - } - - try jw.objectField("children"); - try jw.beginArray(); - var it = node.childrenIterator(); - while (it.next()) |child| { - try self.dumpJson(child, jw, xpath); - } - try jw.endArray(); - - try jw.endObject(); -} +}; fn isStructuralRole(role: []const u8) bool { return std.mem.eql(u8, role, "none") or @@ -236,134 +276,74 @@ fn isStructuralRole(role: []const u8) bool { std.mem.eql(u8, role, "InlineTextBox"); } -fn dumpText(self: Self, node: *Node, writer: *std.Io.Writer, depth: usize) !void { - // 1. Skip non-content nodes - if (node.is(Element)) |el| { - const tag = el.getTag(); - if (tag.isMetadata() or tag == .svg) return; +const TextVisitor = struct { + writer: *std.Io.Writer, + tree: Self, + depth: usize, - // CSS display: none visibility check (inline style only for now) - if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { - if (std.mem.indexOf(u8, style, "display: none") != null or - std.mem.indexOf(u8, style, "display:none") != null) - { - return; + pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool { + // Pruning Heuristic: + // If it's a structural node (none/generic) and has no unique label, unwrap it. + // We only keep 'none'/'generic' if they are interactive. + const structural = isStructuralRole(data.role); + const has_explicit_label = if (node.is(Element)) |el| + el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null + else + false; + + if (structural and !data.is_interactive and !has_explicit_label) { + // Just unwrap (don't print this node, but visit children at same depth) + return true; + } + + // Skip redundant StaticText nodes if the parent already captures the text + if (std.mem.eql(u8, data.role, "StaticText") and node._parent != null) { + const parent_axn = AXNode.fromNode(node._parent.?); + const parent_name = try parent_axn.getName(self.tree.page, self.tree.arena); + if (parent_name != null and data.name != null and std.mem.indexOf(u8, parent_name.?, data.name.?) != null) { + return false; } } - if (el.is(Element.Html)) |html_el| { - if (html_el.getHidden()) return; + // Format: " [12] link: Hacker News (value)" + for (0..(self.depth * 2)) |_| { + try self.writer.writeByte(' '); } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; - const text = text_node.getWholeText(); - if (isAllWhitespace(text)) { - return; - } - } else if (node._type != .document and node._type != .document_fragment) { - return; - } + try self.writer.print("[{d}] {s}: ", .{ data.id, data.role }); - const cdp_node = try self.registry.register(node); - const axn = AXNode.fromNode(node); - const role = try axn.getRole(); - - var is_interactive = false; - var value: ?[]const u8 = null; - - if (node.is(Element)) |el| { - const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; - is_interactive = ax_role.isInteractive(); - - const event_target = node.asEventTarget(); - if (self.page._event_manager.hasListener(event_target, "click") or - self.page._event_manager.hasListener(event_target, "mousedown") or - self.page._event_manager.hasListener(event_target, "mouseup") or - self.page._event_manager.hasListener(event_target, "keydown") or - self.page._event_manager.hasListener(event_target, "change") or - self.page._event_manager.hasListener(event_target, "input")) - { - is_interactive = true; - } - - if (el.is(Element.Html)) |html_el| { - if (html_el.hasAttributeFunction(.onclick, self.page) or - html_el.hasAttributeFunction(.onmousedown, self.page) or - html_el.hasAttributeFunction(.onmouseup, self.page) or - html_el.hasAttributeFunction(.onkeydown, self.page) or - html_el.hasAttributeFunction(.onchange, self.page) or - html_el.hasAttributeFunction(.oninput, self.page)) - { - is_interactive = true; + if (data.name) |n| { + if (n.len > 0) { + try self.writer.writeAll(n); + } + } else if (node.is(CData.Text) != null) { + const text_node = node.is(CData.Text).?; + const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); + if (trimmed.len > 0) { + try self.writer.writeAll(trimmed); } } - if (el.is(Element.Html.Input)) |input| { - value = input.getValue(); - } else if (el.is(Element.Html.TextArea)) |textarea| { - value = textarea.getValue(); - } else if (el.is(Element.Html.Select)) |select| { - value = select.getValue(self.page); + if (data.value) |v| { + if (v.len > 0) { + try self.writer.print(" (value: {s})", .{v}); + } } + + try self.writer.writeByte('\n'); + self.depth += 1; + return true; } - const name = try axn.getName(self.page, self.arena); + pub fn leave(self: *TextVisitor, node: *Node, data: *NodeData) !void { + const structural = isStructuralRole(data.role); + const has_explicit_label = if (node.is(Element)) |el| + el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null + else + false; - // Pruning Heuristic: - // If it's a structural node (none/generic) and has no unique label, unwrap it. - // We only keep 'none'/'generic' if they are interactive. - const structural = isStructuralRole(role); - const has_explicit_label = if (node.is(Element)) |el| - el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null - else - false; - - if (structural and !is_interactive and !has_explicit_label) { - // Just unwrap and process children - var it = node.childrenIterator(); - while (it.next()) |child| { - try self.dumpText(child, writer, depth); - } - return; - } - - // Skip redundant StaticText nodes if the parent already captures the text - if (std.mem.eql(u8, role, "StaticText") and node._parent != null) { - const parent_axn = AXNode.fromNode(node._parent.?); - const parent_name = try parent_axn.getName(self.page, self.arena); - if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) { + if (structural and !data.is_interactive and !has_explicit_label) { return; } + self.depth -= 1; } - - // Format: " [12] link: Hacker News (value)" - for (0..(depth * 2)) |_| { - try writer.writeByte(' '); - } - try writer.print("[{d}] {s}: ", .{ cdp_node.id, role }); - - if (name) |n| { - if (n.len > 0) { - try writer.writeAll(n); - } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; - const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); - if (trimmed.len > 0) { - try writer.writeAll(trimmed); - } - } - - if (value) |v| { - if (v.len > 0) { - try writer.print(" (value: {s})", .{v}); - } - } - - try writer.writeByte('\n'); - - var it = node.childrenIterator(); - while (it.next()) |child| { - try self.dumpText(child, writer, depth + 1); - } -} +}; From 330dfccb89bfe14911af9e27fcba4c9f9fdd9683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 11:23:52 +0900 Subject: [PATCH 13/32] webapi/Element: add missing block tags and reorganize checks --- src/browser/webapi/Element.zig | 44 ++++++++++++---------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 9da4df71..2b5db1f7 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1582,45 +1582,31 @@ pub const Tag = enum { } pub fn isBlock(self: Tag) bool { + // zig fmt: off return switch (self) { - .p, - .div, - .section, - .article, - .main, - .header, - .footer, - .nav, - .aside, - .h1, - .h2, - .h3, - .h4, - .h5, - .h6, - .ul, - .ol, - .blockquote, - .pre, + // Semantic Layout + .article, .aside, .footer, .header, .main, .nav, .section, + // Grouping / Containers + .address, .div, .fieldset, .figure, .p, + // Headings + .h1, .h2, .h3, .h4, .h5, .h6, + // Lists + .dl, .ol, .ul, + // Preformatted / Quotes + .blockquote, .pre, + // Tables .table, + // Other .hr, => true, else => false, }; + // zig fmt: on } pub fn isMetadata(self: Tag) bool { return switch (self) { - .script, - .style, - .meta, - .link, - .noscript, - .head, - .title, - .base, - .template, - => true, + .base, .head, .link, .meta, .noscript, .script, .style, .template, .title => true, else => false, }; } From b8a3135835cbbb05e8b1bf22904dbe0938352d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 13:02:03 +0900 Subject: [PATCH 14/32] SemanticTree: add pruning support and move logic to walk --- src/SemanticTree.zig | 74 ++++++++++++++++++++---------------------- src/cdp/domains/lp.zig | 5 ++- src/mcp/tools.zig | 1 + 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 03b48ec4..d9b5b1e6 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -35,6 +35,7 @@ dom_node: *Node, registry: *CDPNode.Registry, page: *Page, arena: std.mem.Allocator, +prune: bool = false, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; @@ -155,12 +156,39 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) .node_name = node_name, }; - if (try visitor.visit(node, &data)) { - var it = node.childrenIterator(); - while (it.next()) |child| { - try self.walk(child, xpath, visitor); + var should_visit = true; + if (self.prune) { + const structural = isStructuralRole(role); + const has_explicit_label = if (node.is(Element)) |el| + el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null + else + false; + + if (structural and !is_interactive and !has_explicit_label) { + should_visit = false; } - try visitor.leave(node, &data); + + if (std.mem.eql(u8, role, "StaticText") and node._parent != null) { + const parent_axn = AXNode.fromNode(node._parent.?); + const parent_name = try parent_axn.getName(self.page, self.arena); + if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) { + should_visit = false; + } + } + } + + var did_visit = false; + if (should_visit) { + did_visit = try visitor.visit(node, &data); + } + + var it = node.childrenIterator(); + while (it.next()) |child| { + try self.walk(child, xpath, visitor); + } + + if (did_visit) { + try visitor.leave(); } } @@ -264,7 +292,7 @@ const JsonVisitor = struct { return true; } - pub fn leave(self: *JsonVisitor, _: *Node, _: *NodeData) !void { + pub fn leave(self: *JsonVisitor) !void { try self.jw.endArray(); try self.jw.endObject(); } @@ -282,29 +310,6 @@ const TextVisitor = struct { depth: usize, pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool { - // Pruning Heuristic: - // If it's a structural node (none/generic) and has no unique label, unwrap it. - // We only keep 'none'/'generic' if they are interactive. - const structural = isStructuralRole(data.role); - const has_explicit_label = if (node.is(Element)) |el| - el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null - else - false; - - if (structural and !data.is_interactive and !has_explicit_label) { - // Just unwrap (don't print this node, but visit children at same depth) - return true; - } - - // Skip redundant StaticText nodes if the parent already captures the text - if (std.mem.eql(u8, data.role, "StaticText") and node._parent != null) { - const parent_axn = AXNode.fromNode(node._parent.?); - const parent_name = try parent_axn.getName(self.tree.page, self.tree.arena); - if (parent_name != null and data.name != null and std.mem.indexOf(u8, parent_name.?, data.name.?) != null) { - return false; - } - } - // Format: " [12] link: Hacker News (value)" for (0..(self.depth * 2)) |_| { try self.writer.writeByte(' '); @@ -334,16 +339,7 @@ const TextVisitor = struct { return true; } - pub fn leave(self: *TextVisitor, node: *Node, data: *NodeData) !void { - const structural = isStructuralRole(data.role); - const has_explicit_label = if (node.is(Element)) |el| - el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null - else - false; - - if (structural and !data.is_interactive and !has_explicit_label) { - return; - } + pub fn leave(self: *TextVisitor) !void { self.depth -= 1; } }; diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 2470a312..6d853e76 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -39,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void { fn getSemanticTree(cmd: anytype) !void { const Params = struct { format: ?[]const u8 = null, + prune: ?bool = null, }; const params = (try cmd.params(Params)) orelse Params{}; @@ -46,15 +47,17 @@ fn getSemanticTree(cmd: anytype) !void { const page = bc.session.currentPage() orelse return error.PageNotLoaded; const dom_node = page.document.asNode(); - const st = SemanticTree{ + var st = SemanticTree{ .dom_node = dom_node, .registry = &bc.node_registry, .page = page, .arena = cmd.arena, + .prune = params.prune orelse false, }; if (params.format) |format| { if (std.mem.eql(u8, format, "text")) { + st.prune = params.prune orelse true; // text format defaults to pruned var aw: std.Io.Writer.Allocating = .init(cmd.arena); defer aw.deinit(); try st.textStringify(&aw.writer); diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index ebdd0408..a475b987 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -134,6 +134,7 @@ const ToolStreamingText = struct { .registry = self.registry.?, .page = self.page, .arena = self.arena.?, + .prune = true, }; st.textStringify(w) catch |err| { From 0a5eb935659aa2d1df3f4d1d2f298adbc4e9a1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 13:42:09 +0900 Subject: [PATCH 15/32] SemanticTree: Implement compound component metadata --- src/SemanticTree.zig | 112 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index d9b5b1e6..6561bb21 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -53,12 +53,19 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v }; } +const OptionData = struct { + value: []const u8, + text: []const u8, + selected: bool, +}; + const NodeData = struct { id: u32, axn: AXNode, role: []const u8, name: ?[]const u8, value: ?[]const u8, + options: ?[]OptionData = null, xpath: []const u8, is_interactive: bool, node_name: []const u8, @@ -70,6 +77,9 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) const tag = el.getTag(); if (tag.isMetadata() or tag == .svg) return; + // We handle options/optgroups natively inside their parents, skip them in the general walk + if (tag == .datalist or tag == .option or tag == .optgroup) return; + // CSS display: none visibility check (inline style only for now) if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { if (std.mem.indexOf(u8, style, "display: none") != null or @@ -98,6 +108,7 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) var is_interactive = false; var value: ?[]const u8 = null; + var options: ?[]OptionData = null; var node_name: []const u8 = "text"; if (node.is(Element)) |el| { @@ -131,10 +142,14 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) if (el.is(Element.Html.Input)) |input| { value = input.getValue(); + if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { + options = try extractDataListOptions(list_id, self.page, self.arena); + } } else if (el.is(Element.Html.TextArea)) |textarea| { value = textarea.getValue(); } else if (el.is(Element.Html.Select)) |select| { value = select.getValue(self.page); + options = try extractSelectOptions(el.asNode(), self.page, self.arena); } } else if (node._type == .document or node._type == .document_fragment) { node_name = "root"; @@ -151,6 +166,7 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) .role = role, .name = name, .value = value, + .options = options, .xpath = xpath, .is_interactive = is_interactive, .node_name = node_name, @@ -178,13 +194,22 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) } var did_visit = false; + var should_walk_children = true; if (should_visit) { - did_visit = try visitor.visit(node, &data); + should_walk_children = try visitor.visit(node, &data); + did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures + } else { + // If we skip the node, we must NOT tell the visitor to close it later + did_visit = false; } - var it = node.childrenIterator(); - while (it.next()) |child| { - try self.walk(child, xpath, visitor); + if (should_walk_children) { + // If we are printing this node normally OR skipping it and unrolling its children, + // we walk the children iterator. + var it = node.childrenIterator(); + while (it.next()) |child| { + try self.walk(child, xpath, visitor); + } } if (did_visit) { @@ -192,6 +217,45 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) } } +fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData { + var options = std.ArrayListUnmanaged(OptionData){}; + var it = node.childrenIterator(); + while (it.next()) |child| { + if (child.is(Element)) |el| { + if (el.getTag() == .option) { + if (el.is(Element.Html.Option)) |opt| { + const text = opt.getText(); + const value = opt.getValue(page); + const selected = opt.getSelected(); + try options.append(arena, .{ .text = text, .value = value, .selected = selected }); + } + } else if (el.getTag() == .optgroup) { + var group_it = child.childrenIterator(); + while (group_it.next()) |group_child| { + if (group_child.is(Element.Html.Option)) |opt| { + const text = opt.getText(); + const value = opt.getValue(page); + const selected = opt.getSelected(); + try options.append(arena, .{ .text = text, .value = value, .selected = selected }); + } + } + } + } + } + return options.toOwnedSlice(arena); +} + +fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData { + const doc = page.document.asNode(); + const datalist = @import("browser/webapi/selector/Selector.zig").querySelector(doc, try std.fmt.allocPrint(arena, "#{s}", .{list_id}), page) catch null; + if (datalist) |dl| { + if (dl.getTag() == .datalist) { + return try extractSelectOptions(dl.asNode(), page, arena); + } + } + return null; +} + fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); @@ -276,6 +340,22 @@ const JsonVisitor = struct { } try self.jw.endObject(); } + + if (data.options) |options| { + try self.jw.objectField("options"); + try self.jw.beginArray(); + for (options) |opt| { + try self.jw.beginObject(); + try self.jw.objectField("value"); + try self.jw.write(opt.value); + try self.jw.objectField("text"); + try self.jw.write(opt.text); + try self.jw.objectField("selected"); + try self.jw.write(opt.selected); + try self.jw.endObject(); + } + try self.jw.endArray(); + } } else if (node.is(CData.Text) != null) { const text_node = node.is(CData.Text).?; try self.jw.objectField("nodeType"); @@ -289,6 +369,12 @@ const JsonVisitor = struct { try self.jw.objectField("children"); try self.jw.beginArray(); + + if (data.options != null) { + // Signal to not walk children, as we handled them natively + return false; + } + return true; } @@ -334,12 +420,28 @@ const TextVisitor = struct { } } + if (data.options) |options| { + try self.writer.writeAll(" options: ["); + for (options, 0..) |opt, i| { + if (i > 0) try self.writer.writeAll(", "); + try self.writer.print("'{s}'", .{opt.value}); + if (opt.selected) { + try self.writer.writeAll(" (selected)"); + } + } + try self.writer.writeAll("]\n"); + self.depth += 1; + return false; // Native handling complete, do not walk children + } + try self.writer.writeByte('\n'); self.depth += 1; return true; } pub fn leave(self: *TextVisitor) !void { - self.depth -= 1; + if (self.depth > 0) { + self.depth -= 1; + } } }; From c3a53752e7bb2163ca57942ef72e5b2a832494a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 15:32:18 +0900 Subject: [PATCH 16/32] CDP: simplify AXNode name extraction logic --- src/cdp/AXNode.zig | 56 +++++++++++++--------------------------------- 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index c74a02fc..00149943 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -760,9 +760,8 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons var aw: std.Io.Writer.Allocating = .init(allocator); defer aw.deinit(); - // We need to bypass the strict JSON writer used in writeName - // We'll create a dummy writer that just writes to our buffer - const DummyWriter = struct { + // writeName expects a std.json.Stringify instance. + const TextCaptureWriter = struct { aw: *std.Io.Writer.Allocating, writer: *std.Io.Writer, @@ -773,52 +772,27 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons } else if (comptime std.meta.hasMethod(T, "format")) { try std.fmt.format(w.aw.writer, "{s}", .{val}); } else { - // Ignore unexpected types to avoid garbage output + // Ignore unexpected types (e.g. booleans) to avoid garbage output } } - pub fn beginWriteRaw(w: @This()) !void { - _ = w; - } - pub fn endWriteRaw(w: @This()) void { - _ = w; - } - pub fn writeByte(w: @This(), b: u8) !void { - try w.aw.writer.writeByte(b); - } - pub fn writeAll(w: @This(), s: []const u8) !void { - try w.aw.writer.writeAll(s); - } - - // Mock object methods - pub fn objectField(w: @This(), name: []const u8) !void { - _ = w; - _ = name; - } - pub fn beginObject(w: @This()) !void { - _ = w; - } - pub fn endObject(w: @This()) !void { - _ = w; - } - pub fn beginArray(w: @This()) !void { - _ = w; - } - pub fn endArray(w: @This()) !void { - _ = w; - } + // Mock JSON Stringifier lifecycle methods + pub fn beginWriteRaw(_: @This()) !void {} + pub fn endWriteRaw(_: @This()) void {} + pub fn objectField(_: @This(), _: []const u8) !void {} + pub fn beginObject(_: @This()) !void {} + pub fn endObject(_: @This()) !void {} + pub fn beginArray(_: @This()) !void {} + pub fn endArray(_: @This()) !void {} }; - const w = DummyWriter{ .aw = &aw, .writer = &aw.writer }; + const w = TextCaptureWriter{ .aw = &aw, .writer = &aw.writer }; const source = try self.writeName(w, page); if (source != null) { - var str = aw.written(); - // writeString manually injects literal quotes for JSON, we need to strip them - if (str.len >= 2 and str[0] == '"' and str[str.len - 1] == '"') { - str = str[1 .. str.len - 1]; - } - return try allocator.dupe(u8, str); + // Remove literal quotes inserted by writeString. + const raw_text = std.mem.trim(u8, aw.written(), "\""); + return try allocator.dupe(u8, raw_text); } return null; From 3c97332fd8440cda2e95f1fc50a6b9755c5867dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 18:23:52 +0900 Subject: [PATCH 17/32] feat(dump): add semantic_tree and semantic_tree_text formats Adds support for dumping the semantic tree in JSON or text format via the --dump option. Updates the Config enum and usage help. --- src/Config.zig | 4 +++- src/lightpanda.zig | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Config.zig b/src/Config.zig index 5a4cc58e..f93c0efa 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -192,6 +192,8 @@ pub const DumpFormat = enum { html, markdown, wpt, + semantic_tree, + semantic_tree_text, }; pub const Fetch = struct { @@ -338,7 +340,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\Options: \\--dump Dumps document to stdout. - \\ Argument must be 'html' or 'markdown'. + \\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'. \\ Defaults to no dump. \\ \\--strip_mode Comma separated list of tag groups to remove from dump diff --git a/src/lightpanda.zig b/src/lightpanda.zig index e506e61b..d11a9ff5 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -32,6 +32,7 @@ pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); +pub const CDPNode = @import("cdp/Node.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); @@ -108,6 +109,24 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { switch (mode) { .html => try dump.root(page.window._document, opts.dump, writer, page), .markdown => try markdown.dump(page.window._document.asNode(), .{}, writer, page), + .semantic_tree, .semantic_tree_text => { + var registry = CDPNode.Registry.init(app.allocator); + defer registry.deinit(); + + const st = SemanticTree{ + .dom_node = page.window._document.asNode(), + .registry = ®istry, + .page = page, + .arena = page.call_arena, + .prune = true, + }; + + if (mode == .semantic_tree) { + try std.json.Stringify.value(st, .{}, writer); + } else { + try st.textStringify(writer); + } + }, .wpt => try dumpWPT(page, writer), } } From 85ebbe8759ab8a32c5f4d5de428e8a2969ea01b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 9 Mar 2026 21:04:47 +0900 Subject: [PATCH 18/32] SemanticTree: improve accessibility tree and name calculation - Add more structural roles (banner, navigation, main, list, etc.). - Implement fallback for accessible names (SVG titles, image alt text). - Skip children for leaf-like semantic nodes to reduce redundancy. - Disable pruning in the default semantic tree view. --- src/SemanticTree.zig | 21 ++++++++++++++++++++- src/cdp/AXNode.zig | 44 ++++++++++++++++++++++++++++++++++++++++---- src/lightpanda.zig | 4 ++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 6561bb21..2b6c96e3 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -385,9 +385,17 @@ const JsonVisitor = struct { }; fn isStructuralRole(role: []const u8) bool { + // zig fmt: off return std.mem.eql(u8, role, "none") or std.mem.eql(u8, role, "generic") or - std.mem.eql(u8, role, "InlineTextBox"); + std.mem.eql(u8, role, "InlineTextBox") or + std.mem.eql(u8, role, "banner") or + std.mem.eql(u8, role, "navigation") or + std.mem.eql(u8, role, "main") or + std.mem.eql(u8, role, "list") or + std.mem.eql(u8, role, "listitem") or + std.mem.eql(u8, role, "region"); + // zig fmt: on } const TextVisitor = struct { @@ -436,6 +444,17 @@ const TextVisitor = struct { try self.writer.writeByte('\n'); self.depth += 1; + + // If this is a leaf-like semantic node and we already have a name, + // skip children to avoid redundant StaticText or noise. + const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or + std.mem.eql(u8, data.role, "button") or + std.mem.eql(u8, data.role, "heading") or + std.mem.eql(u8, data.role, "code"); + if (is_leaf_semantic and data.name != null and data.name.?.len > 0) { + return false; + } + return true; } diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 00149943..16da8478 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -888,10 +888,12 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource { => {}, else => { // write text content if exists. - var buf = std.Io.Writer.Allocating.init(page.call_arena); - try el.getInnerText(&buf.writer); - try writeString(buf.written(), w); - return .contents; + var buf: std.Io.Writer.Allocating = .init(page.call_arena); + try writeAccessibleNameFallback(node, &buf.writer, page); + if (buf.written().len > 0) { + try writeString(buf.written(), w); + return .contents; + } }, } @@ -915,6 +917,40 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource { }; } +fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Page) !void { + var it = node.childrenIterator(); + while (it.next()) |child| { + switch (child._type) { + .cdata => |cd| switch (cd._type) { + .text => |*text| try writer.writeAll(text.getWholeText()), + else => {}, + }, + .element => |el| { + if (el.getTag() == .img) { + if (el.getAttributeSafe(.wrap("alt"))) |alt| { + try writer.writeAll(alt); + try writer.writeByte(' '); + } + } else if (el.getTag() == .svg) { + // Try to find a inside SVG + var sit = child.childrenIterator(); + while (sit.next()) |s_child| { + if (s_child.is(DOMNode.Element)) |s_el| { + if (std.mem.eql(u8, s_el.getTagNameLower(), "title")) { + try writeAccessibleNameFallback(s_child, writer, page); + try writer.writeByte(' '); + } + } + } + } else { + try writeAccessibleNameFallback(child, writer, page); + } + }, + else => {}, + } + } +} + fn isHidden(elt: *DOMNode.Element) bool { if (elt.getAttributeSafe(comptime .wrap("aria-hidden"))) |value| { if (std.mem.eql(u8, value, "true")) { diff --git a/src/lightpanda.zig b/src/lightpanda.zig index d11a9ff5..c4abae6c 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -113,12 +113,12 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { var registry = CDPNode.Registry.init(app.allocator); defer registry.deinit(); - const st = SemanticTree{ + const st: SemanticTree = .{ .dom_node = page.window._document.asNode(), .registry = ®istry, .page = page, .arena = page.call_arena, - .prune = true, + .prune = false, }; if (mode == .semantic_tree) { From 83ba974f94d4fcd46d92784a9f9394f411b0f2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Mon, 9 Mar 2026 22:30:19 +0900 Subject: [PATCH 19/32] SemanticTree: optimize tree walking and xpath generation - Use a reusable buffer for XPaths to reduce allocations. - Improve `display: none` detection with proper CSS parsing. - Pass parent name to children to avoid redundant AXNode lookups. - Use `getElementById` for faster datalist lookups. --- src/SemanticTree.zig | 57 ++++++++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 2b6c96e3..737e0903 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -39,7 +39,8 @@ prune: bool = false, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; - self.walk(self.dom_node, "", &visitor) catch |err| { + var xpath_buffer: std.ArrayList(u8) = .{}; + self.walk(self.dom_node, &xpath_buffer, null, &visitor) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -47,7 +48,8 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; - self.walk(self.dom_node, "", &visitor) catch |err| { + var xpath_buffer: std.ArrayList(u8) = .empty; + self.walk(self.dom_node, &xpath_buffer, null, &visitor) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -71,7 +73,26 @@ const NodeData = struct { node_name: []const u8, }; -fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) !void { +fn isDisplayNone(style: []const u8) bool { + var it = std.mem.splitScalar(u8, style, ';'); + while (it.next()) |decl| { + var decl_it = std.mem.splitScalar(u8, decl, ':'); + const prop = decl_it.next() orelse continue; + const value = decl_it.next() orelse continue; + + const prop_trimmed = std.mem.trim(u8, prop, &std.ascii.whitespace); + const value_trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); + + if (std.ascii.eqlIgnoreCase(prop_trimmed, "display") and + std.ascii.eqlIgnoreCase(value_trimmed, "none")) + { + return true; + } + } + return false; +} + +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -82,9 +103,7 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) // CSS display: none visibility check (inline style only for now) if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { - if (std.mem.indexOf(u8, style, "display: none") != null or - std.mem.indexOf(u8, style, "display:none") != null) - { + if (isDisplayNone(style)) { return; } } @@ -155,8 +174,9 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) node_name = "root"; } - const segment = try self.getXPathSegment(node); - const xpath = try std.mem.concat(self.arena, u8, &.{ parent_xpath, segment }); + const initial_xpath_len = xpath_buffer.items.len; + try appendXPathSegment(node, xpath_buffer.writer(self.arena)); + const xpath = xpath_buffer.items; const name = try axn.getName(self.page, self.arena); @@ -185,8 +205,6 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) } if (std.mem.eql(u8, role, "StaticText") and node._parent != null) { - const parent_axn = AXNode.fromNode(node._parent.?); - const parent_name = try parent_axn.getName(self.page, self.arena); if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) { should_visit = false; } @@ -208,13 +226,15 @@ fn walk(self: @This(), node: *Node, parent_xpath: []const u8, visitor: anytype) // we walk the children iterator. var it = node.childrenIterator(); while (it.next()) |child| { - try self.walk(child, xpath, visitor); + try self.walk(child, xpath_buffer, name, visitor); } } if (did_visit) { try visitor.leave(); } + + xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); } fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData { @@ -246,17 +266,15 @@ fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]O } fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData { - const doc = page.document.asNode(); - const datalist = @import("browser/webapi/selector/Selector.zig").querySelector(doc, try std.fmt.allocPrint(arena, "#{s}", .{list_id}), page) catch null; - if (datalist) |dl| { - if (dl.getTag() == .datalist) { - return try extractSelectOptions(dl.asNode(), page, arena); + if (page.document.getElementById(list_id, page)) |referenced_el| { + if (referenced_el.getTag() == .datalist) { + return try extractSelectOptions(referenced_el.asNode(), page, arena); } } return null; } -fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { +fn appendXPathSegment(node: *Node, writer: anytype) !void { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); var index: usize = 1; @@ -272,7 +290,7 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { } } } - return std.fmt.allocPrint(self.arena, "/{s}[{d}]", .{ tag, index }); + try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index }); } else if (node.is(CData.Text) != null) { var index: usize = 1; if (node._parent) |parent| { @@ -284,9 +302,8 @@ fn getXPathSegment(self: @This(), node: *Node) ![]const u8 { } } } - return std.fmt.allocPrint(self.arena, "/text()[{d}]", .{index}); + try std.fmt.format(writer, "/text()[{d}]", .{index}); } - return ""; } const JsonVisitor = struct { From a318c6263dfc5c681fd2b132d8a28794feebff41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Tue, 10 Mar 2026 09:23:06 +0900 Subject: [PATCH 20/32] SemanticTree: improve visibility, AX roles and xpath generation - Use `checkVisibility` for more accurate element visibility detection. - Add support for color, date, file, and month AX roles. - Optimize XPath generation by tracking sibling indices during the walk. - Refine interactivity detection for form elements. --- src/SemanticTree.zig | 106 +++++++++++++++++-------------------------- src/cdp/AXNode.zig | 26 +++++++---- 2 files changed, 59 insertions(+), 73 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 737e0903..c713e5bb 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -40,7 +40,7 @@ prune: bool = false, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; var xpath_buffer: std.ArrayList(u8) = .{}; - self.walk(self.dom_node, &xpath_buffer, null, &visitor) catch |err| { + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -49,7 +49,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; var xpath_buffer: std.ArrayList(u8) = .empty; - self.walk(self.dom_node, &xpath_buffer, null, &visitor) catch |err| { + self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -73,26 +73,7 @@ const NodeData = struct { node_name: []const u8, }; -fn isDisplayNone(style: []const u8) bool { - var it = std.mem.splitScalar(u8, style, ';'); - while (it.next()) |decl| { - var decl_it = std.mem.splitScalar(u8, decl, ':'); - const prop = decl_it.next() orelse continue; - const value = decl_it.next() orelse continue; - - const prop_trimmed = std.mem.trim(u8, prop, &std.ascii.whitespace); - const value_trimmed = std.mem.trim(u8, value, &std.ascii.whitespace); - - if (std.ascii.eqlIgnoreCase(prop_trimmed, "display") and - std.ascii.eqlIgnoreCase(value_trimmed, "none")) - { - return true; - } - } - return false; -} - -fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype) !void { +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -101,11 +82,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam // We handle options/optgroups natively inside their parents, skip them in the general walk if (tag == .datalist or tag == .option or tag == .optgroup) return; - // CSS display: none visibility check (inline style only for now) - if (el.getAttributeSafe(comptime lp.String.wrap("style"))) |style| { - if (isDisplayNone(style)) { - return; - } + // Check visibility using the engine's checkVisibility which handles CSS display: none + if (!el.checkVisibility(self.page)) { + return; } if (el.is(Element.Html)) |html_el| { @@ -136,6 +115,26 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; is_interactive = ax_role.isInteractive(); + if (el.is(Element.Html.Input)) |input| { + // Force all non-hidden inputs to be interactive + if (input._input_type != .hidden) { + is_interactive = true; + } + value = input.getValue(); + if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { + options = try extractDataListOptions(list_id, self.page, self.arena); + } + } else if (el.is(Element.Html.TextArea)) |textarea| { + is_interactive = true; + value = textarea.getValue(); + } else if (el.is(Element.Html.Select)) |select| { + is_interactive = true; + value = select.getValue(self.page); + options = try extractSelectOptions(el.asNode(), self.page, self.arena); + } else if (el.getTag() == .button) { + is_interactive = true; + } + const event_target = node.asEventTarget(); if (self.page._event_manager.hasListener(event_target, "click") or self.page._event_manager.hasListener(event_target, "mousedown") or @@ -158,24 +157,12 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam is_interactive = true; } } - - if (el.is(Element.Html.Input)) |input| { - value = input.getValue(); - if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { - options = try extractDataListOptions(list_id, self.page, self.arena); - } - } else if (el.is(Element.Html.TextArea)) |textarea| { - value = textarea.getValue(); - } else if (el.is(Element.Html.Select)) |select| { - value = select.getValue(self.page); - options = try extractSelectOptions(el.asNode(), self.page, self.arena); - } } else if (node._type == .document or node._type == .document_fragment) { node_name = "root"; } const initial_xpath_len = xpath_buffer.items.len; - try appendXPathSegment(node, xpath_buffer.writer(self.arena)); + try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); const xpath = xpath_buffer.items; const name = try axn.getName(self.page, self.arena); @@ -225,8 +212,20 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam // If we are printing this node normally OR skipping it and unrolling its children, // we walk the children iterator. var it = node.childrenIterator(); + var tag_counts = std.StringArrayHashMap(usize).init(self.arena); while (it.next()) |child| { - try self.walk(child, xpath_buffer, name, visitor); + var tag: []const u8 = "text()"; + if (child.is(Element)) |el| { + tag = el.getTagNameLower(); + } + + const gop = try tag_counts.getOrPut(tag); + if (!gop.found_existing) { + gop.value_ptr.* = 0; + } + gop.value_ptr.* += 1; + + try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*); } } @@ -274,34 +273,11 @@ fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Alloc return null; } -fn appendXPathSegment(node: *Node, writer: anytype) !void { +fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); - var index: usize = 1; - - if (node._parent) |parent| { - var it = parent.childrenIterator(); - while (it.next()) |sibling| { - if (sibling == node) break; - if (sibling.is(Element)) |s_el| { - if (std.mem.eql(u8, s_el.getTagNameLower(), tag)) { - index += 1; - } - } - } - } try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index }); } else if (node.is(CData.Text) != null) { - var index: usize = 1; - if (node._parent) |parent| { - var it = parent.childrenIterator(); - while (it.next()) |sibling| { - if (sibling == node) break; - if (sibling.is(CData.Text) != null) { - index += 1; - } - } - } try std.fmt.format(writer, "/text()[{d}]", .{index}); } } diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 16da8478..73571e7d 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -557,10 +557,10 @@ pub const Writer = struct { pub const AXRole = enum(u8) { // zig fmt: off - none, article, banner, blockquote, button, caption, cell, checkbox, code, - columnheader, combobox, complementary, contentinfo, definition, deletion, - dialog, document, emphasis, figure, form, group, heading, image, insertion, - link, list, listbox, listitem, main, marquee, menuitem, meter, navigation, option, + none, article, banner, blockquote, button, caption, cell, checkbox, code, color, + columnheader, combobox, complementary, contentinfo, date, definition, deletion, + dialog, document, emphasis, figure, file, form, group, heading, image, insertion, + link, list, listbox, listitem, main, marquee, menuitem, meter, month, navigation, option, paragraph, presentation, progressbar, radio, region, row, rowgroup, rowheader, searchbox, separator, slider, spinbutton, status, strong, subscript, superscript, @"switch", table, term, textbox, time, RootWebArea, LineBreak, @@ -580,6 +580,10 @@ pub const AXRole = enum(u8) { .spinbutton, .@"switch", .menuitem, + .color, + .date, + .file, + .month, => true, else => false, }; @@ -638,9 +642,13 @@ pub const AXRole = enum(u8) { .number => .spinbutton, .search => .searchbox, .checkbox => .checkbox, + .color => .color, + .date => .date, + .file => .file, + .month => .month, + .@"datetime-local", .week, .time => .combobox, // zig fmt: off - .password, .@"datetime-local", .hidden, .month, .color, - .week, .time, .file, .date => .none, + .password, .hidden => .none, // zig fmt: on }; }, @@ -883,7 +891,7 @@ fn writeName(axnode: AXNode, w: anytype, page: *Page) !?AXSource { .object, .progress, .meter, .main, .nav, .aside, .header, .footer, .form, .section, .article, .ul, .ol, .dl, .menu, .thead, .tbody, .tfoot, .tr, .td, .div, .span, .p, .details, .li, - .style, .script, + .style, .script, .html, .body, // zig fmt: on => {}, else => { @@ -943,7 +951,9 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Pa } } } else { - try writeAccessibleNameFallback(child, writer, page); + if (!el.getTag().isMetadata()) { + try writeAccessibleNameFallback(child, writer, page); + } } }, else => {}, From 064e7b404b4abcd8b3b4ad8be3a54abf3e0361a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Tue, 10 Mar 2026 19:02:55 +0900 Subject: [PATCH 21/32] SemanticTree: unify interactivity detection logic --- src/SemanticTree.zig | 47 +++++++++++-------------------------- src/browser/interactive.zig | 6 ++--- src/cdp/AXNode.zig | 22 ----------------- 3 files changed, 17 insertions(+), 58 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index c713e5bb..7bea8866 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -22,6 +22,7 @@ const lp = @import("lightpanda"); const log = @import("log.zig"); const isAllWhitespace = @import("string.zig").isAllWhitespace; const Page = lp.Page; +const interactive = @import("browser/interactive.zig"); const CData = @import("browser/webapi/CData.zig"); const Element = @import("browser/webapi/Element.zig"); @@ -40,7 +41,11 @@ prune: bool = false, pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void { var visitor = JsonVisitor{ .jw = jw, .tree = self }; var xpath_buffer: std.ArrayList(u8) = .{}; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { + const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { + 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| { log.err(.app, "semantic tree json dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -49,7 +54,11 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}! pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void { var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 }; var xpath_buffer: std.ArrayList(u8) = .empty; - self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1) catch |err| { + const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| { + 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| { log.err(.app, "semantic tree text dump failed", .{ .err = err }); return error.WriteFailed; }; @@ -73,7 +82,7 @@ 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) !void { +fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void { // 1. Skip non-content nodes if (node.is(Element)) |el| { const tag = el.getTag(); @@ -112,48 +121,20 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (node.is(Element)) |el| { node_name = el.getTagNameLower(); - const ax_role = std.meta.stringToEnum(AXNode.AXRole, role) orelse .none; - is_interactive = ax_role.isInteractive(); - if (el.is(Element.Html.Input)) |input| { - // Force all non-hidden inputs to be interactive - if (input._input_type != .hidden) { - is_interactive = true; - } value = input.getValue(); if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| { options = try extractDataListOptions(list_id, self.page, self.arena); } } else if (el.is(Element.Html.TextArea)) |textarea| { - is_interactive = true; value = textarea.getValue(); } else if (el.is(Element.Html.Select)) |select| { - is_interactive = true; value = select.getValue(self.page); options = try extractSelectOptions(el.asNode(), self.page, self.arena); - } else if (el.getTag() == .button) { - is_interactive = true; - } - - const event_target = node.asEventTarget(); - if (self.page._event_manager.hasListener(event_target, "click") or - self.page._event_manager.hasListener(event_target, "mousedown") or - self.page._event_manager.hasListener(event_target, "mouseup") or - self.page._event_manager.hasListener(event_target, "keydown") or - self.page._event_manager.hasListener(event_target, "change") or - self.page._event_manager.hasListener(event_target, "input")) - { - is_interactive = true; } if (el.is(Element.Html)) |html_el| { - if (html_el.hasAttributeFunction(.onclick, self.page) or - html_el.hasAttributeFunction(.onmousedown, self.page) or - html_el.hasAttributeFunction(.onmouseup, self.page) or - html_el.hasAttributeFunction(.onkeydown, self.page) or - html_el.hasAttributeFunction(.onchange, self.page) or - html_el.hasAttributeFunction(.oninput, self.page)) - { + if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) { is_interactive = true; } } @@ -225,7 +206,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.*); + try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets); } } diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index b0428a6c..5e85382f 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -178,12 +178,12 @@ pub fn collectInteractiveElements( return results.items; } -const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8)); +pub const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8)); /// Pre-build a map from event_target pointer → list of event type names. /// This lets both classifyInteractivity (O(1) "has any?") and /// getListenerTypes (O(1) "which ones?") avoid re-iterating per element. -fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { +pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { var map = ListenerTargetMap{}; // addEventListener registrations @@ -209,7 +209,7 @@ fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap { return map; } -fn classifyInteractivity( +pub fn classifyInteractivity( el: *Element, html_el: *Element.Html, listener_targets: ListenerTargetMap, diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 73571e7d..b2cc236e 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -567,28 +567,6 @@ pub const AXRole = enum(u8) { StaticText, // zig fmt: on - pub fn isInteractive(self: AXRole) bool { - return switch (self) { - .button, - .link, - .checkbox, - .radio, - .textbox, - .combobox, - .searchbox, - .slider, - .spinbutton, - .@"switch", - .menuitem, - .color, - .date, - .file, - .month, - => true, - else => false, - }; - } - fn fromNode(node: *DOMNode) !AXRole { return switch (node._type) { .document => return .RootWebArea, // Chrome specific. From a6ccc72d1519c649e489f4af1c01ff86bcad4e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 09:57:08 +0900 Subject: [PATCH 22/32] interactive: properly concatenate text content for accessible names This fixes a bug where only the first text node was being returned, causing fragmented text nodes (e.g. <span>Sub</span><span>mit</span>) to be missing their trailing text. --- src/browser/interactive.zig | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 5e85382f..ed8af8d0 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -157,7 +157,7 @@ pub fn collectInteractiveElements( .node = node, .tag_name = el.getTagNameLower(), .role = getRole(el), - .name = getAccessibleName(el), + .name = try getAccessibleName(el, arena), .interactivity_type = itype, .listener_types = listener_types, .disabled = isDisabled(el), @@ -296,7 +296,7 @@ fn getRole(el: *Element) ?[]const u8 { }; } -fn getAccessibleName(el: *Element) ?[]const u8 { +fn getAccessibleName(el: *Element, arena: Allocator) !?[]const u8 { // aria-label if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| { if (v.len > 0) return v; @@ -325,11 +325,14 @@ fn getAccessibleName(el: *Element) ?[]const u8 { } // Text content (first non-empty text node, trimmed) - return getTextContent(el.asNode()); + return try getTextContent(el.asNode(), arena); } -fn getTextContent(node: *Node) ?[]const u8 { - var tw = TreeWalker.FullExcludeSelf.init(node, .{}); +fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 { + var tw: TreeWalker.FullExcludeSelf = .init(node, .{}); + + var chunks: std.ArrayList([]const u8) = .empty; + while (tw.next()) |child| { // Skip text inside script/style elements. if (child.is(Element)) |el| { @@ -344,13 +347,18 @@ fn getTextContent(node: *Node) ?[]const u8 { if (child.is(Node.CData)) |cdata| { if (cdata.is(Node.CData.Text)) |text| { const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace); - if (content.len > 0) return content; + if (content.len > 0) { + try chunks.append(arena, content); + } } } } - return null; -} + if (chunks.items.len == 0) return null; + if (chunks.items.len == 1) return chunks.items[0]; + + return try std.mem.join(arena, " ", chunks.items); +} fn isDisabled(el: *Element) bool { if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true; return isDisabledByFieldset(el); From 4f262e5bed767d81ce71ecc77a2591ef5389d4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 10:22:40 +0900 Subject: [PATCH 23/32] SemanticTree: filter computed names for generic containers This prevents token bloat in JSON/text dumps and ensures that StaticText leaf nodes are not incorrectly pruned when structural containers (like none, table) hoist their text. --- src/SemanticTree.zig | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 7bea8866..f77eeb22 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -146,7 +146,21 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); const xpath = xpath_buffer.items; - const name = try axn.getName(self.page, self.arena); + var name = try axn.getName(self.page, self.arena); + + const has_explicit_label = if (node.is(Element)) |el| + el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null + else + false; + + const structural = isStructuralRole(role); + + // Filter out computed concatenated names for generic containers without explicit labels. + // This prevents token bloat and ensures their StaticText children aren't incorrectly pruned. + // We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text. + if (name != null and structural and !has_explicit_label) { + name = null; + } var data = NodeData{ .id = cdp_node.id, @@ -162,12 +176,6 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam var should_visit = true; if (self.prune) { - const structural = isStructuralRole(role); - const has_explicit_label = if (node.is(Element)) |el| - el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null - else - false; - if (structural and !is_interactive and !has_explicit_label) { should_visit = false; } @@ -368,6 +376,10 @@ fn isStructuralRole(role: []const u8) bool { std.mem.eql(u8, role, "main") or std.mem.eql(u8, role, "list") or std.mem.eql(u8, role, "listitem") or + std.mem.eql(u8, role, "table") or + std.mem.eql(u8, role, "rowgroup") or + std.mem.eql(u8, role, "row") or + std.mem.eql(u8, role, "cell") or std.mem.eql(u8, role, "region"); // zig fmt: on } From 6c7272061cd221007f47e21b444090dc66622ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 10:38:12 +0900 Subject: [PATCH 24/32] cli: enable pruning for semantic_tree_text dump mode Previously, semantic_tree_text hardcoded prune = false, which bypassed the structural node filters and allowed empty none nodes to pollute the root of the text dump. --- src/lightpanda.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lightpanda.zig b/src/lightpanda.zig index be9d727f..03bced65 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -120,7 +120,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { .registry = ®istry, .page = page, .arena = page.call_arena, - .prune = false, + .prune = (mode == .semantic_tree_text), }; if (mode == .semantic_tree) { From ca931a11be5015ef48df5e5a9b8edbf8becade7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 10:45:07 +0900 Subject: [PATCH 25/32] AXNode: add spacing between concatenated text nodes When calculating accessible names for elements without explicit labels, multiple descendant text nodes were previously concatenated directly together. This adds a space between distinct text node contents to prevent words from sticking together. --- src/cdp/AXNode.zig | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index b2cc236e..5fc633ec 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -777,7 +777,8 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons const source = try self.writeName(w, page); if (source != null) { // Remove literal quotes inserted by writeString. - const raw_text = std.mem.trim(u8, aw.written(), "\""); + var raw_text = std.mem.trim(u8, aw.written(), "\""); + raw_text = std.mem.trim(u8, raw_text, &std.ascii.whitespace); return try allocator.dupe(u8, raw_text); } @@ -908,7 +909,13 @@ fn writeAccessibleNameFallback(node: *DOMNode, writer: *std.Io.Writer, page: *Pa while (it.next()) |child| { switch (child._type) { .cdata => |cd| switch (cd._type) { - .text => |*text| try writer.writeAll(text.getWholeText()), + .text => |*text| { + const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace); + if (content.len > 0) { + try writer.writeAll(content); + try writer.writeByte(' '); + } + }, else => {}, }, .element => |el| { From 2e6dd3edfeed666f37d478e034222e7e2ed8ba4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 15:18:14 +0900 Subject: [PATCH 26/32] browser.EventManager: remove unused hasListener function --- src/browser/EventManager.zig | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index ccbb1336..573aa4f9 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -98,14 +98,6 @@ pub const Callback = union(enum) { object: js.Object, }; -pub fn hasListener(self: *EventManager, target: *EventTarget, typ: []const u8) bool { - const type_string = String.wrap(typ); - return self.lookup.contains(.{ - .event_target = @intFromPtr(target), - .type_string = type_string, - }); -} - pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void { if (comptime IS_DEBUG) { log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() }); From 5329d05005f02c742264955b6c8ee2925292f4c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 15:27:12 +0900 Subject: [PATCH 27/32] interactive: optimize getTextContent single-chunk path Avoids an unnecessary double allocation and maintains a zero-copy fast path for single-chunk text extraction. --- src/browser/interactive.zig | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index ed8af8d0..2d03db51 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -331,7 +331,8 @@ fn getAccessibleName(el: *Element, arena: Allocator) !?[]const u8 { fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 { var tw: TreeWalker.FullExcludeSelf = .init(node, .{}); - var chunks: std.ArrayList([]const u8) = .empty; + var arr: std.ArrayList(u8) = .empty; + var single_chunk: ?[]const u8 = null; while (tw.next()) |child| { // Skip text inside script/style elements. @@ -348,16 +349,27 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 { if (cdata.is(Node.CData.Text)) |text| { const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace); if (content.len > 0) { - try chunks.append(arena, content); + if (single_chunk == null and arr.items.len == 0) { + single_chunk = content; + } else { + if (single_chunk) |sc| { + try arr.appendSlice(arena, sc); + try arr.append(arena, ' '); + single_chunk = null; + } + try arr.appendSlice(arena, content); + try arr.append(arena, ' '); + } } } } } - if (chunks.items.len == 0) return null; - if (chunks.items.len == 1) return chunks.items[0]; + if (single_chunk) |sc| return sc; + if (arr.items.len == 0) return null; - return try std.mem.join(arena, " ", chunks.items); + // strip out trailing space + return arr.items[0 .. arr.items.len - 1]; } fn isDisabled(el: *Element) bool { if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true; From af803da5c800b555a413a4323399daaf83f5f75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 16:21:43 +0900 Subject: [PATCH 28/32] cdp.lp: use enum for getSemanticTree format param Leverages std.json.parse to automatically validate the format param into a type-safe enum. --- src/cdp/domains/lp.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 3cb876e8..2026b17d 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -44,7 +44,7 @@ pub fn processMessage(cmd: anytype) !void { fn getSemanticTree(cmd: anytype) !void { const Params = struct { - format: ?[]const u8 = null, + format: ?enum { text } = null, prune: ?bool = null, }; const params = (try cmd.params(Params)) orelse Params{}; @@ -62,8 +62,8 @@ fn getSemanticTree(cmd: anytype) !void { }; if (params.format) |format| { - if (std.mem.eql(u8, format, "text")) { - st.prune = params.prune orelse true; // text format defaults to pruned + if (format == .text) { + st.prune = params.prune orelse true; var aw: std.Io.Writer.Allocating = .init(cmd.arena); defer aw.deinit(); try st.textStringify(&aw.writer); From feccc9f5cec445ceae5468e875621fc05bf0628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 16:25:34 +0900 Subject: [PATCH 29/32] AXNode: remove unused mock JSON lifecycle methods Simplifies TextCaptureWriter by removing unused methods, ensuring future changes to writeName will fail at build time if new methods are required. --- src/cdp/AXNode.zig | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 5fc633ec..953b7d3a 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -765,11 +765,6 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons // Mock JSON Stringifier lifecycle methods pub fn beginWriteRaw(_: @This()) !void {} pub fn endWriteRaw(_: @This()) void {} - pub fn objectField(_: @This(), _: []const u8) !void {} - pub fn beginObject(_: @This()) !void {} - pub fn endObject(_: @This()) !void {} - pub fn beginArray(_: @This()) !void {} - pub fn endArray(_: @This()) !void {} }; const w = TextCaptureWriter{ .aw = &aw, .writer = &aw.writer }; From 1866e7141ebfc5eb73fed096c940903901f1e668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <1671644+arrufat@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:33:39 +0900 Subject: [PATCH 30/32] SemanticTree: cast with as Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com> --- src/SemanticTree.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index f77eeb22..316b65e6 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -100,7 +100,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (html_el.getHidden()) return; } } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; + const text_node = node.as(CData.Text); const text = text_node.getWholeText(); if (isAllWhitespace(text)) { return; From 37735b1caaf5facc6bc76adecdb6ab9ad5f89ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 16:37:24 +0900 Subject: [PATCH 31/32] SemanticTree: use StaticStringMap for structural role check Improves performance and readability of isStructuralRole. Also includes minor syntax cleanup in AXNode. --- src/SemanticTree.zig | 31 ++++++++++++++++--------------- src/cdp/AXNode.zig | 2 +- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 316b65e6..def373c4 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -367,21 +367,22 @@ const JsonVisitor = struct { }; fn isStructuralRole(role: []const u8) bool { - // zig fmt: off - return std.mem.eql(u8, role, "none") or - std.mem.eql(u8, role, "generic") or - std.mem.eql(u8, role, "InlineTextBox") or - std.mem.eql(u8, role, "banner") or - std.mem.eql(u8, role, "navigation") or - std.mem.eql(u8, role, "main") or - std.mem.eql(u8, role, "list") or - std.mem.eql(u8, role, "listitem") or - std.mem.eql(u8, role, "table") or - std.mem.eql(u8, role, "rowgroup") or - std.mem.eql(u8, role, "row") or - std.mem.eql(u8, role, "cell") or - std.mem.eql(u8, role, "region"); - // zig fmt: on + const structural_roles = std.StaticStringMap(void).initComptime(.{ + .{ "none", {} }, + .{ "generic", {} }, + .{ "InlineTextBox", {} }, + .{ "banner", {} }, + .{ "navigation", {} }, + .{ "main", {} }, + .{ "list", {} }, + .{ "listitem", {} }, + .{ "table", {} }, + .{ "rowgroup", {} }, + .{ "row", {} }, + .{ "cell", {} }, + .{ "region", {} }, + }); + return structural_roles.has(role); } const TextVisitor = struct { diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 953b7d3a..718d0bab 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -767,7 +767,7 @@ pub fn getName(self: AXNode, page: *Page, allocator: std.mem.Allocator) !?[]cons pub fn endWriteRaw(_: @This()) void {} }; - const w = TextCaptureWriter{ .aw = &aw, .writer = &aw.writer }; + const w: TextCaptureWriter = .{ .aw = &aw, .writer = &aw.writer }; const source = try self.writeName(w, page); if (source != null) { From 65d7a39554f783ee566dc62364b82c20a36f3b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Wed, 11 Mar 2026 16:39:59 +0900 Subject: [PATCH 32/32] SemanticTree: use payload captures for CData.Text checks Improves conciseness and idiomatic Zig style by replacing .is(CData.Text) != null and .as() with direct payload captures in if statements. --- src/SemanticTree.zig | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index def373c4..8f5eb755 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -99,8 +99,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam if (el.is(Element.Html)) |html_el| { if (html_el.getHidden()) return; } - } else if (node.is(CData.Text) != null) { - const text_node = node.as(CData.Text); + } else if (node.is(CData.Text)) |text_node| { const text = text_node.getWholeText(); if (isAllWhitespace(text)) { return; @@ -266,7 +265,7 @@ fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void { if (node.is(Element)) |el| { const tag = el.getTagNameLower(); try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index }); - } else if (node.is(CData.Text) != null) { + } else if (node.is(CData.Text)) |_| { try std.fmt.format(writer, "/text()[{d}]", .{index}); } } @@ -338,8 +337,7 @@ const JsonVisitor = struct { } try self.jw.endArray(); } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; + } else if (node.is(CData.Text)) |text_node| { try self.jw.objectField("nodeType"); try self.jw.write(3); try self.jw.objectField("nodeValue"); @@ -401,8 +399,7 @@ const TextVisitor = struct { if (n.len > 0) { try self.writer.writeAll(n); } - } else if (node.is(CData.Text) != null) { - const text_node = node.is(CData.Text).?; + } else if (node.is(CData.Text)) |text_node| { const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); if (trimmed.len > 0) { try self.writer.writeAll(trimmed);