// 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 jsonStringify = std.json.Stringify; const log = @import("../log.zig"); const Page = @import("../browser/Page.zig"); const DOMNode = @import("../browser/webapi/Node.zig"); const Node = @import("Node.zig"); const AXNode = @This(); // Need a custom writer, because we can't just serialize the node as-is. // Sometimes we want to serializ the node without chidren, sometimes with just // its direct children, and sometimes the entire tree. // (For now, we only support direct children) pub const Writer = struct { root: *const Node, registry: *Node.Registry, page: *Page, pub const Opts = struct {}; pub fn jsonStringify(self: *const Writer, w: anytype) error{WriteFailed}!void { self.toJSON(self.root, w) catch |err| { // The only error our jsonStringify method can return is // @TypeOf(w).Error. In other words, our code can't return its own // error, we can only return a writer error. Kinda sucks. log.err(.cdp, "node toJSON stringify", .{ .err = err }); return error.WriteFailed; }; } fn toJSON(self: *const Writer, node: *const Node, w: anytype) !void { try w.beginArray(); const root = AXNode.fromNode(node.dom); if (try self.writeNode(node.id, root, w)) { try self.writeNodeChildren(root, w); } return w.endArray(); } fn writeNodeChildren(self: *const Writer, parent: AXNode, w: anytype) !void { // Add ListMarker for listitem elements if (parent.dom.is(DOMNode.Element)) |parent_el| { if (parent_el.getTag() == .li) { try self.writeListMarker(parent.dom, w); } } var it = parent.dom.childrenIterator(); const ignore_text = ignoreText(parent.dom); while (it.next()) |dom_node| { switch (dom_node._type) { .cdata => { if (dom_node.is(DOMNode.CData.Text) == null) { continue; } if (ignore_text) { continue; } }, .element => {}, else => continue, } const node = try self.registry.register(dom_node); const axn = AXNode.fromNode(node.dom); if (try self.writeNode(node.id, axn, w)) { try self.writeNodeChildren(axn, w); } } } fn writeListMarker(self: *const Writer, li_node: *DOMNode, w: anytype) !void { // Find the parent list element const parent = li_node._parent orelse return; const parent_el = parent.is(DOMNode.Element) orelse return; const list_type = parent_el.getTag(); // Only create markers for actual list elements switch (list_type) { .ul, .ol, .menu => {}, else => return, } // Write the ListMarker node try w.beginObject(); // Use the next available ID for the marker try w.objectField("nodeId"); const marker_id = self.registry.node_id; self.registry.node_id += 1; try w.write(marker_id); try w.objectField("backendDOMNodeId"); try w.write(marker_id); try w.objectField("role"); try self.writeAXValue(.{ .role = "ListMarker" }, w); try w.objectField("ignored"); try w.write(false); try w.objectField("name"); try w.beginObject(); try w.objectField("type"); try w.write("computedString"); try w.objectField("value"); // Write marker text directly based on list type switch (list_type) { .ul, .menu => try w.write("• "), .ol => { // Calculate the list item number by counting preceding li siblings var count: usize = 1; var it = parent.childrenIterator(); while (it.next()) |child| { if (child == li_node) break; if (child.is(DOMNode.Element.Html) == null) continue; const child_el = child.as(DOMNode.Element); if (child_el.getTag() == .li) count += 1; } // Sanity check: lists with >9999 items are unrealistic if (count > 9999) return error.ListTooLong; // Use a small stack buffer to format the number (max "9999. " = 6 chars) var buf: [6]u8 = undefined; const marker_text = try std.fmt.bufPrint(&buf, "{d}. ", .{count}); try w.write(marker_text); }, else => unreachable, } try w.objectField("sources"); try w.beginArray(); try w.beginObject(); try w.objectField("type"); try w.write("contents"); try w.endObject(); try w.endArray(); try w.endObject(); try w.objectField("properties"); try w.beginArray(); try w.endArray(); // Get the parent node ID for the parentId field const li_registered = try self.registry.register(li_node); try w.objectField("parentId"); try w.write(li_registered.id); try w.objectField("childIds"); try w.beginArray(); try w.endArray(); try w.endObject(); } const AXValue = union(enum) { role: []const u8, string: []const u8, computedString: []const u8, integer: usize, boolean: bool, booleanOrUndefined: bool, token: []const u8, // TODO not implemented: // tristate, idrefList, node, nodeList, number, tokenList, // domRelation, internalRole, valueUndefined, }; fn writeAXSource(_: *const Writer, source: AXSource, w: anytype) !void { try w.objectField("sources"); try w.beginArray(); try w.beginObject(); // attribute, implicit, style, contents, placeholder, relatedElement const source_type = switch (source) { .aria_labelledby => blk: { try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "relatedElement"; }, .aria_label, .alt, .title, .placeholder, .value => blk: { // No sure if it's correct for .value case. try w.objectField("attribute"); try w.write(@tagName(source)); break :blk "attribute"; }, // Chrome sends the content AXValue *again* in the source. // But It seems useless to me. // // w.objectField("value"); // self.writeAXValue(.{ .type = .computedString, .value = value.value }, w); .contents => "contents", .label_element, .label_wrap => "TODO", // TODO }; try w.objectField("type"); try w.write(source_type); try w.endObject(); try w.endArray(); } fn writeAXValue(_: *const Writer, value: AXValue, w: anytype) !void { try w.beginObject(); try w.objectField("type"); try w.write(@tagName(std.meta.activeTag(value))); try w.objectField("value"); switch (value) { inline else => |v| try w.write(v), } try w.endObject(); } const AXProperty = struct { // zig fmt: off name: enum(u8) { actions, busy, disabled, editable, focusable, focused, hidden, hiddenRoot, invalid, keyshortcuts, settable, roledescription, live, atomic, relevant, root, autocomplete, hasPopup, level, multiselectable, orientation, multiline, readonly, required, valuemin, valuemax, valuetext, checked, expanded, modal, pressed, selected, activedescendant, controls, describedby, details, errormessage, flowto, labelledby, owns, url, activeFullscreenElement, activeModalDialog, activeAriaModalDialog, ariaHiddenElement, ariaHiddenSubtree, emptyAlt, emptyText, inertElement, inertSubtree, labelContainer, labelFor, notRendered, notVisible, presentationalRole, probablyPresentational, inactiveCarouselTabContent, uninteresting, }, // zig fmt: on value: AXValue, }; fn writeAXProperties(self: *const Writer, axnode: AXNode, w: anytype) !void { const dom_node = axnode.dom; const page = self.page; switch (dom_node._type) { .document => |document| { const uri = document.getURL(page); try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); try self.writeAXProperty(.{ .name = .focused, .value = .{ .booleanOrUndefined = true } }, w); return; }, .cdata => return, .element => |el| switch (el.getTag()) { .h1 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 1 } }, w), .h2 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 2 } }, w), .h3 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 3 } }, w), .h4 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 4 } }, w), .h5 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 5 } }, w), .h6 => try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = 6 } }, w), .img => { const img = el.as(DOMNode.Element.Html.Image); const uri = try img.getSrc(self.page); if (uri.len == 0) return; try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); }, .anchor => { const a = el.as(DOMNode.Element.Html.Anchor); const uri = try a.getHref(self.page); if (uri.len == 0) return; try self.writeAXProperty(.{ .name = .url, .value = .{ .string = uri } }, w); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); }, .input => { const input = el.as(DOMNode.Element.Html.Input); const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); switch (input._input_type) { .text, .email, .tel, .url, .search, .password, .number => { if (is_disabled) { try self.writeAXProperty(.{ .name = .disabled, .value = .{ .boolean = true } }, w); } try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = false } }, w); try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("readonly")) } }, w); try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w); }, .button, .submit, .reset, .image => { try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } }, .checkbox, .radio => { try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } const is_checked = el.hasAttributeSafe(comptime .wrap("checked")); try self.writeAXProperty(.{ .name = .checked, .value = .{ .token = if (is_checked) "true" else "false" } }, w); }, else => {}, } }, .textarea => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .editable, .value = .{ .token = "plaintext" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .multiline, .value = .{ .boolean = true } }, w); try self.writeAXProperty(.{ .name = .readonly, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("readonly")) } }, w); try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w); }, .select => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } try self.writeAXProperty(.{ .name = .hasPopup, .value = .{ .token = "menu" } }, w); try self.writeAXProperty(.{ .name = .expanded, .value = .{ .booleanOrUndefined = false } }, w); }, .option => { const option = el.as(DOMNode.Element.Html.Option); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); // Check if this option is selected by examining the parent select const is_selected = blk: { // First check if explicitly selected if (option.getSelected()) break :blk true; // Check if implicitly selected (first enabled option in select with no explicit selection) const parent = dom_node._parent orelse break :blk false; const parent_el = parent.as(DOMNode.Element); if (parent_el.getTag() != .select) break :blk false; const select = parent_el.as(DOMNode.Element.Html.Select); const selected_idx = select.getSelectedIndex(); // Find this option's index var idx: i32 = 0; var it = parent.childrenIterator(); while (it.next()) |child| { if (child.is(DOMNode.Element.Html.Option) == null) continue; if (child == dom_node) { break :blk idx == selected_idx; } idx += 1; } break :blk false; }; if (is_selected) { try self.writeAXProperty(.{ .name = .selected, .value = .{ .booleanOrUndefined = true } }, w); } }, .button => { const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); if (!is_disabled) { try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); } }, .hr => { try self.writeAXProperty(.{ .name = .settable, .value = .{ .booleanOrUndefined = true } }, w); try self.writeAXProperty(.{ .name = .orientation, .value = .{ .token = "horizontal" } }, w); }, .li => { // Calculate level by counting list ancestors (ul, ol, menu) var level: usize = 0; var current = dom_node._parent; while (current) |node| { if (node.is(DOMNode.Element) == null) { current = node._parent; continue; } const current_el = node.as(DOMNode.Element); switch (current_el.getTag()) { .ul, .ol, .menu => level += 1, else => {}, } current = node._parent; } try self.writeAXProperty(.{ .name = .level, .value = .{ .integer = level } }, w); }, else => {}, }, else => |tag| { log.debug(.cdp, "invalid tag", .{ .tag = tag }); return error.InvalidTag; }, } } fn writeAXProperty(self: *const Writer, value: AXProperty, w: anytype) !void { try w.beginObject(); try w.objectField("name"); try w.write(@tagName(value.name)); try w.objectField("value"); try self.writeAXValue(value.value, w); try w.endObject(); } // write a node. returns true if children must be written. fn writeNode(self: *const Writer, id: u32, axn: AXNode, w: anytype) !bool { // ignore empty texts try w.beginObject(); try w.objectField("nodeId"); try w.write(id); try w.objectField("backendDOMNodeId"); try w.write(id); try w.objectField("role"); try self.writeAXValue(.{ .role = try axn.getRole() }, w); const ignore = axn.isIgnore(self.page); try w.objectField("ignored"); try w.write(ignore); if (ignore) { // Ignore reasons try w.objectField("ignoredReasons"); try w.beginArray(); try w.beginObject(); try w.objectField("name"); try w.write("uninteresting"); try w.objectField("value"); try self.writeAXValue(.{ .boolean = true }, w); try w.endObject(); try w.endArray(); } else { // Name try w.objectField("name"); try w.beginObject(); try w.objectField("type"); try w.write(@tagName(.computedString)); try w.objectField("value"); const source = try axn.writeName(w, self.page); if (source) |s| { try self.writeAXSource(s, w); } try w.endObject(); // Value (for form controls) try self.writeNodeValue(axn, w); // Properties try w.objectField("properties"); try w.beginArray(); try self.writeAXProperties(axn, w); try w.endArray(); } const n = axn.dom; // Parent if (n._parent) |p| { const parent_node = try self.registry.register(p); try w.objectField("parentId"); try w.write(parent_node.id); } // Children const write_children = axn.ignoreChildren() == false; const skip_text = ignoreText(axn.dom); try w.objectField("childIds"); try w.beginArray(); if (write_children) { var registry = self.registry; var it = n.childrenIterator(); while (it.next()) |child| { // ignore non-elements or text. if (child.is(DOMNode.Element.Html) == null and (child.is(DOMNode.CData.Text) == null or skip_text)) { continue; } const child_node = try registry.register(child); try w.write(child_node.id); } } try w.endArray(); try w.endObject(); return write_children; } fn writeNodeValue(self: *const Writer, axnode: AXNode, w: anytype) !void { const node = axnode.dom; if (node.is(DOMNode.Element.Html) == null) { return; } const el = node.as(DOMNode.Element); const value: ?[]const u8 = switch (el.getTag()) { .input => blk: { const input = el.as(DOMNode.Element.Html.Input); const val = input.getValue(); if (val.len == 0) break :blk null; break :blk val; }, .textarea => blk: { const textarea = el.as(DOMNode.Element.Html.TextArea); const val = textarea.getValue(); if (val.len == 0) break :blk null; break :blk val; }, .select => blk: { const select = el.as(DOMNode.Element.Html.Select); const val = select.getValue(self.page); if (val.len == 0) break :blk null; break :blk val; }, else => null, }; if (value) |val| { try w.objectField("value"); try self.writeAXValue(.{ .string = val }, w); } } }; 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, paragraph, presentation, progressbar, radio, region, row, rowgroup, rowheader, searchbox, separator, slider, spinbutton, status, strong, 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. .cdata => |cd| { if (cd.is(DOMNode.CData.Text) == null) { log.debug(.cdp, "invalid tag", .{ .tag = cd }); return error.InvalidTag; } return .StaticText; }, .element => |el| switch (el.getTag()) { // Navigation & Structure .nav => .navigation, .main => .main, .aside => .complementary, // TODO conditions: // .banner Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .header => .banner, // TODO conditions: // contentinfo Not descendant of article, aside, main, nav, section // (none) When descendant of article, aside, main, nav, section .footer => .contentinfo, // TODO conditions: // region Has accessible name (aria-label, aria-labelledby, or title) | // (none) No accessible name | .section => .region, .article, .hgroup => .article, .address => .group, // Headings .h1, .h2, .h3, .h4, .h5, .h6 => .heading, .ul, .ol, .menu => .list, .li => .listitem, .dt => .term, .dd => .definition, // Forms & Inputs // TODO conditions: // form Has accessible name // (none) No accessible name .form => .form, .input => { const input = el.as(DOMNode.Element.Html.Input); return switch (input._input_type) { .tel, .url, .email, .text => .textbox, .image, .reset, .button, .submit => .button, .radio => .radio, .range => .slider, .number => .spinbutton, .search => .searchbox, .checkbox => .checkbox, // zig fmt: off .password, .@"datetime-local", .hidden, .month, .color, .week, .time, .file, .date => .none, // zig fmt: on }; }, .textarea => .textbox, .select => { if (el.getAttributeSafe(comptime .wrap("multiple")) != null) { return .listbox; } if (el.getAttributeSafe(comptime .wrap("size"))) |size| { if (!std.ascii.eqlIgnoreCase(size, "1")) { return .listbox; } } return .combobox; }, .option => .option, .optgroup, .fieldset => .group, .button => .button, .output => .status, .progress => .progressbar, .meter => .meter, .datalist => .listbox, // Interactive Elements .anchor, .area => { if (el.getAttributeSafe(comptime .wrap("href")) == null) { return .none; } return .link; }, .details => .group, .summary => .button, .dialog => .dialog, // Media .img => .image, .figure => .figure, // Tables .table => .table, .caption => .caption, .thead, .tbody, .tfoot => .rowgroup, .tr => .row, .th => { if (el.getAttributeSafe(comptime .wrap("scope"))) |scope| { if (std.ascii.eqlIgnoreCase(scope, "row")) { return .rowheader; } } return .columnheader; }, .td => .cell, // Text & Semantics .p => .paragraph, .hr => .separator, .blockquote => .blockquote, .code => .code, .em => .emphasis, .strong => .strong, .s, .del => .deletion, .ins => .insertion, .sub => .subscript, .sup => .superscript, .time => .time, .dfn => .term, // Document Structure .html => .none, .body => .none, // Deprecated/Obsolete Elements .marquee => .marquee, .br => .LineBreak, else => .none, }, else => |tag| { log.debug(.cdp, "invalid tag", .{ .tag = tag }); return error.InvalidTag; }, }; } }; dom: *DOMNode, role_attr: ?[]const u8, pub fn fromNode(dom: *DOMNode) AXNode { return .{ .dom = dom, .role_attr = blk: { if (dom.is(DOMNode.Element.Html) == null) { break :blk null; } const elt = dom.as(DOMNode.Element); break :blk elt.getAttributeSafe(comptime .wrap("role")); }, }; } const AXSource = enum(u8) { aria_labelledby, aria_label, label_element, //