diff --git a/src/browser/Page.zig b/src/browser/Page.zig index bb0c0b1d..913014cd 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1367,7 +1367,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const Element.Html.Quote, namespace, attribute_iterator, - .{ ._proto = undefined, ._tag_name = String.init(undefined, "q", .{}) catch unreachable, ._tag = .unknown }, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "q", .{}) catch unreachable, ._tag = .quote }, ), 's' => return self.createHtmlElementT( Element.Html.Generic, @@ -1523,7 +1523,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const Element.Html.TableCol, namespace, attribute_iterator, - .{ ._proto = undefined, ._tag_name = String.init(undefined, "col", .{}) catch unreachable, ._tag = .unknown }, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "col", .{}) catch unreachable, ._tag = .col }, ), asUint("dir") => return self.createHtmlElementT( Element.Html.Directory, @@ -1926,7 +1926,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const Element.Html.TableCol, namespace, attribute_iterator, - .{ ._proto = undefined, ._tag_name = String.init(undefined, "colgroup", .{}) catch unreachable, ._tag = .unknown }, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "colgroup", .{}) catch unreachable, ._tag = .colgroup }, ), asUint("fieldset") => return self.createHtmlElementT( Element.Html.FieldSet, @@ -1952,6 +1952,12 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const attribute_iterator, .{ ._proto = undefined }, ), + asUint("noscript") => return self.createHtmlElementT( + Element.Html.Generic, + namespace, + attribute_iterator, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "noscript", .{}) catch unreachable, ._tag = .noscript }, + ), else => {}, }, 10 => switch (@as(u80, @bitCast(name[0..10].*))) { diff --git a/src/browser/tests/cdp/dom3.html b/src/browser/tests/cdp/dom3.html new file mode 100644 index 00000000..9dc6c0e1 --- /dev/null +++ b/src/browser/tests/cdp/dom3.html @@ -0,0 +1,25 @@ + + + + Test Page + + +

Test Page

+ +
+ + + + + + + + + + +
+ + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 828f726b..8bdf1a88 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1219,6 +1219,8 @@ pub const Tag = enum { caption, circle, code, + col, + colgroup, custom, data, datalist, @@ -1265,20 +1267,26 @@ pub const Tag = enum { meta, meter, nav, + noscript, + object, ol, optgroup, option, + output, p, path, + param, polygon, polyline, progress, + quote, rect, s, script, section, select, slot, + source, span, strong, style, @@ -1298,6 +1306,7 @@ pub const Tag = enum { thead, title, tr, + track, ul, video, unknown, diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig new file mode 100644 index 00000000..e887d75e --- /dev/null +++ b/src/cdp/AXNode.zig @@ -0,0 +1,1130 @@ +// 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 Allocator = std.mem.Allocator; +const jsonStringify = std.json.Stringify; + +const log = @import("../log.zig"); +const Page = @import("../browser/Page.zig"); +const DOMNode = @import("../browser/webapi/Node.zig"); +const URL = @import("../browser/URL.zig"); + +const AXNode = @This(); +const Node = @import("Node.zig"); + +// 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("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("readonly") } }, w); + try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe("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("checked"); + try self.writeAXProperty(.{ .name = .checked, .value = .{ .token = if (is_checked) "true" else "false" } }, w); + }, + else => {}, + } + }, + .textarea => { + const is_disabled = el.hasAttributeSafe("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("readonly") } }, w); + try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe("required") } }, w); + }, + .select => { + const is_disabled = el.hasAttributeSafe("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("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, 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, + StaticText, + // zig fmt: on + + 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("multiple") != null) { + return .listbox; + } + if (el.getAttributeSafe("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("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("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("role"); + }, + }; +} + +const AXSource = enum(u8) { + aria_labelledby, + aria_label, + label_element, //