///|
/// Accessibility Tree Builder
/// Constructs AccessibilityTree from HTML Document
// =============================================================================
// Tree Builder Context
// =============================================================================
///|
/// Context for building accessibility tree
priv struct TreeBuilderContext {
// Element lookup by id (for aria-labelledby, etc.)
element_map : Map[String, @html.Element]
// Whether the currently indexed id sits under a hidden ancestor.
hidden_ancestor_map : Map[String, Bool]
// Label elements indexed by 'for' attribute
label_map : Map[String, @html.Element]
// Current nesting level for sectioning content
in_sectioning : Bool
// Encapsulating label element (for label wrapping input)
encapsulating_label : @html.Element?
// UID counter for generating unique ids
mut uid_counter : Int
// Parsed stylesheets for pseudo-element content
stylesheets : Array[@css.Stylesheet]
// Ref counter for sequential element refs (ref_1, ref_2, ...)
mut ref_counter : Int
}
///|
fn TreeBuilderContext::new() -> TreeBuilderContext {
{
element_map: {},
hidden_ancestor_map: {},
label_map: {},
in_sectioning: false,
encapsulating_label: None,
uid_counter: 0,
stylesheets: [],
ref_counter: 1,
}
}
///|
fn TreeBuilderContext::with_stylesheets(
self : TreeBuilderContext,
stylesheets : Array[@css.Stylesheet],
) -> TreeBuilderContext {
{ ..self, stylesheets, }
}
///|
fn TreeBuilderContext::next_uid(self : TreeBuilderContext) -> String {
let uid = self.uid_counter
self.uid_counter += 1
"aom-\{uid}"
}
///|
/// Generate next ref id (ref_1, ref_2, ...)
fn TreeBuilderContext::next_ref(self : TreeBuilderContext) -> String {
let ref_num = self.ref_counter
self.ref_counter += 1
"ref_\{ref_num}"
}
///|
fn TreeBuilderContext::find_by_id(
self : TreeBuilderContext,
id : String,
) -> @html.Element? {
self.element_map.get(id)
}
///|
fn TreeBuilderContext::has_hidden_ancestor(
self : TreeBuilderContext,
id : String,
) -> Bool {
self.hidden_ancestor_map.get(id).unwrap_or(false)
}
///|
fn TreeBuilderContext::with_sectioning(
self : TreeBuilderContext,
in_sectioning : Bool,
) -> TreeBuilderContext {
{ ..self, in_sectioning, }
}
///|
fn TreeBuilderContext::with_encapsulating_label(
self : TreeBuilderContext,
label : @html.Element?,
) -> TreeBuilderContext {
{ ..self, encapsulating_label: label }
}
// =============================================================================
// Pseudo-element Content Extraction
// =============================================================================
///|
/// Get content of ::before pseudo-element for an element
fn get_before_content(
element : @html.Element,
ctx : TreeBuilderContext,
) -> String? {
get_pseudo_element_content(element, ctx, true)
}
///|
/// Get content of ::after pseudo-element for an element
fn get_after_content(
element : @html.Element,
ctx : TreeBuilderContext,
) -> String? {
get_pseudo_element_content(element, ctx, false)
}
///|
/// Get content of a pseudo-element for an element
/// is_before: true for ::before, false for ::after
fn get_pseudo_element_content(
element : @html.Element,
ctx : TreeBuilderContext,
is_before : Bool,
) -> String? {
if ctx.stylesheets.is_empty() {
return None
}
// Search through stylesheets for matching rules with the pseudo-element
for stylesheet in ctx.stylesheets {
for rule in stylesheet.rules {
// Check selector text for ::before or ::after
let has_pseudo = if is_before {
rule.selector_text.contains("::before") ||
rule.selector_text.contains(":before")
} else {
rule.selector_text.contains("::after") ||
rule.selector_text.contains(":after")
}
if has_pseudo {
// Try to match by checking if base selector matches the element
// Use the selector_text to build a simpler match
if matches_element_by_class(element, rule.selector_text) {
// Found a match, extract content property
for decl in rule.declarations {
if decl.property == "content" {
match decl.value {
@css.PropertyValue::Value(value) => {
// Remove quotes from string value
let content = parse_content_value(value)
if !content.is_empty() {
return Some(content)
}
}
_ => ()
}
}
}
}
}
}
}
None
}
///|
/// Simple class-based matching for pseudo-element selectors
fn matches_element_by_class(
element : @html.Element,
selector_text : String,
) -> Bool {
// Extract class from selector like ".simple-before::before"
// This is a simplified matcher for common patterns
// Check for class selector
let class_patterns = extract_class_patterns(selector_text)
for pattern in class_patterns {
if element.classes.contains(pattern) {
return true
}
}
// Check for tag selector
let tag = element.tag.to_lower()
if selector_text.has_prefix(tag) {
return true
}
false
}
///|
/// Extract class names from a selector text
fn extract_class_patterns(selector_text : String) -> Array[String] {
let classes : Array[String] = []
let mut i = 0
let dot_code = '.'.to_int().to_uint16()
let hash_code = '#'.to_int().to_uint16()
let colon_code = ':'.to_int().to_uint16()
let bracket_code = '['.to_int().to_uint16()
let space_code = ' '.to_int().to_uint16()
let gt_code = '>'.to_int().to_uint16()
let plus_code = '+'.to_int().to_uint16()
let tilde_code = '~'.to_int().to_uint16()
while i < selector_text.length() {
if selector_text[i] == dot_code {
// Found a class selector
let start = i + 1
let mut end = start
while end < selector_text.length() {
let c = selector_text[end]
if c == dot_code ||
c == hash_code ||
c == colon_code ||
c == bracket_code ||
c == space_code ||
c == gt_code ||
c == plus_code ||
c == tilde_code {
break
}
end += 1
}
if end > start {
// Build substring manually
let buf = StringBuilder::new()
for j = start; j < end; j = j + 1 {
buf.write_char(selector_text[j].to_int().unsafe_to_char())
}
classes.push(buf.to_string())
}
i = end
} else {
i += 1
}
}
classes
}
///|
/// Parse CSS content value (remove quotes, handle escapes)
fn parse_content_value(value : String) -> String {
let trimmed = value.trim().to_owned()
// Check for quoted string
if trimmed.length() >= 2 {
let first = trimmed[0]
let last = trimmed[trimmed.length() - 1]
// Check for double or single quotes
if (first == '"'.to_int().to_uint16() && last == '"'.to_int().to_uint16()) ||
(first == '\''.to_int().to_uint16() && last == '\''.to_int().to_uint16()) {
// Remove quotes - build string manually
let buf = StringBuilder::new()
for i = 1; i < trimmed.length() - 1; i = i + 1 {
buf.write_char(trimmed[i].to_int().unsafe_to_char())
}
return buf.to_string()
}
}
// Handle none, normal (no content)
if trimmed == "none" || trimmed == "normal" {
return ""
}
// Return as-is for other values
trimmed
}
// =============================================================================
// Main Entry Point
// =============================================================================
///|
/// Build an AccessibilityTree from an HTML Document
pub fn build_accessibility_tree(doc : @html.Document) -> AccessibilityTree {
let ctx = TreeBuilderContext::new()
// Parse stylesheets from the document
let stylesheets : Array[@css.Stylesheet] = []
for css_text in doc.stylesheets {
let stylesheet = @css.parse_stylesheet(
@html.flatten_css_cascade_layers(css_text),
)
stylesheets.push(stylesheet)
}
let ctx = ctx.with_stylesheets(stylesheets)
// First pass: index all elements by id and collect labels
index_elements(doc.root, ctx, false)
// Second pass: build the accessibility tree
let root_node = build_node(doc.root, ctx)
AccessibilityTree::new(root_node)
}
///|
/// Build an AccessibilityTree from a single HTML Element
pub fn build_accessibility_tree_from_element(
element : @html.Element,
) -> AccessibilityTree {
let ctx = TreeBuilderContext::new()
index_elements(element, ctx, false)
let root_node = build_node(element, ctx)
AccessibilityTree::new(root_node)
}
// =============================================================================
// Element Indexing (First Pass)
// =============================================================================
///|
/// Index all elements by id for cross-referencing
fn index_elements(
element : @html.Element,
ctx : TreeBuilderContext,
hidden_ancestor : Bool,
) -> Unit {
// Index by id
match element.id {
Some(id) => {
ctx.element_map[id] = element
ctx.hidden_ancestor_map[id] = hidden_ancestor
}
Option::None => ()
}
// Index labels by 'for' attribute
if element.tag.to_lower() == "label" {
match element.attributes.get("for") {
Some(for_id) => ctx.label_map[for_id] = element
Option::None => ()
}
}
let child_hidden_ancestor = hidden_ancestor ||
hides_descendants_from_all_users(element)
// Recurse
for child in element.children {
match child {
@html.Element(child_elem) =>
index_elements(child_elem, ctx, child_hidden_ancestor)
@html.Text(_) => ()
}
}
}
// =============================================================================
// Node Building (Second Pass)
// =============================================================================
///|
/// Build an AccessibilityNode from an HTML Element
fn build_node(
element : @html.Element,
ctx : TreeBuilderContext,
) -> AccessibilityNode {
// Generate unique id
let node_id = match element.id {
Some(id) => id
Option::None => ctx.next_uid()
}
// Compute role with context
let role_context = RoleMappingContext::{
parent_tag: Option::None,
has_accessible_name: has_accessible_name_source(element),
in_sectioning_content: ctx.in_sectioning,
}
let role = compute_role(element, role_context)
// Skip elements that don't contribute to accessibility tree
if should_skip_element(element, role) {
// For script/style/template, skip entirely (don't include their text content)
let tag = element.tag.to_lower()
if tag == "script" ||
tag == "style" ||
tag == "template" ||
tag == "noscript" {
// Return empty node that will be pruned
return {
id: node_id,
role: Generic,
name: Option::None,
description: Option::None,
level: Option::None,
states: [],
properties: {},
bounds: Option::None,
children: [],
focusable: false,
tabindex: Option::None,
source_id: element.id,
href: Option::None,
tag_name: Some(tag),
selector: build_selector(element),
visible: false,
ref_id: Option::None,
input_type: Option::None,
placeholder: Option::None,
input_value: Option::None,
required: false,
text: Option::None,
options: [],
labelled_by: Option::None,
}
}
// For other skipped elements (head, meta, link), pass through children
let children = build_children(element, ctx)
if children.length() == 1 {
return children[0]
}
return {
id: node_id,
role: Generic,
name: Option::None,
description: Option::None,
level: Option::None,
states: [],
properties: {},
bounds: Option::None,
children,
focusable: false,
tabindex: Option::None,
source_id: element.id,
href: Option::None,
tag_name: Some(element.tag.to_lower()),
selector: build_selector(element),
visible: is_visible_element(element.tag),
ref_id: Some(ctx.next_ref()),
input_type: Option::None,
placeholder: Option::None,
input_value: Option::None,
required: false,
text: Option::None,
options: [],
labelled_by: Option::None,
}
}
// Compute accessible name
let find_by_id = fn(id : String) -> @html.Element? { ctx.find_by_id(id) }
let find_label_for_id = fn(id : String) -> @html.Element? {
ctx.label_map.get(id)
}
let is_in_hidden_subtree = fn(target : @html.Element) -> Bool {
match target.id {
Some(id) => ctx.has_hidden_ancestor(id)
None => false
}
}
let base_name = compute_accessible_name_with_hidden_subtree_check(
element,
find_by_id,
find_label_for_id,
ctx.encapsulating_label,
is_in_hidden_subtree,
)
// Add pseudo-element content (::before and ::after)
// Per accname spec, pseudo-element content is concatenated directly
// (the content value itself may contain spaces that act as joiners)
let name = match base_name {
Some(n) => {
let before = get_before_content(element, ctx)
let after = get_after_content(element, ctx)
let buf = StringBuilder::new()
match before {
Some(b) => buf.write_string(b)
None => ()
}
buf.write_string(n)
match after {
Some(a) => buf.write_string(a)
None => ()
}
Some(buf.to_string().trim().to_owned())
}
None => None
}
// Compute accessible description
let description = compute_accessible_description(element, find_by_id)
// Compute heading level
let level = get_heading_level(element)
// Compute states
let states = compute_states(element)
// Check if focusable and get tabindex
let focusable = is_focusable(element, role)
let tabindex = get_tabindex(element)
// Build children (update sectioning context if needed)
let new_ctx = if is_sectioning_content(element.tag) {
ctx.with_sectioning(true)
} else {
ctx
}
let children = build_children(element, new_ctx)
// Extract href for links
let href = extract_href(element)
// Extract form input information
let input_type = extract_input_type(element)
let placeholder = extract_placeholder(element)
let input_value = extract_input_value(element)
let required = is_required(element)
// Extract select options
let options = extract_select_options(element)
// Find labelling element
let labelled_by = find_labelling_ref(element, ctx)
// Aggregate text content for non-interactive elements
let text = if role == Paragraph ||
role == Heading ||
role == ListItem ||
role == Cell ||
role == Caption {
let content = get_element_text_content(element)
if content.is_empty() {
Option::None
} else {
Some(content)
}
} else {
Option::None
}
{
id: node_id,
role,
name,
description,
level,
states,
properties: {},
bounds: Option::None,
children,
focusable,
tabindex,
source_id: element.id,
href,
tag_name: Some(element.tag.to_lower()),
selector: build_selector(element),
visible: is_visible_element(element.tag),
ref_id: Some(ctx.next_ref()),
input_type,
placeholder,
input_value,
required,
text,
options,
labelled_by,
}
}
///|
/// Build child nodes
fn build_children(
element : @html.Element,
ctx : TreeBuilderContext,
) -> Array[AccessibilityNode] {
let children : Array[AccessibilityNode] = []
// Check if this element is a label (for encapsulation tracking)
let child_ctx = if element.tag.to_lower() == "label" {
// If label has 'for' attribute that matches an element, use that association
// Otherwise, it encapsulates its input
match element.attributes.get("for") {
Some(for_id) =>
// Check if the 'for' attribute matches any element by id
match ctx.element_map.get(for_id) {
Some(_) => ctx // Has 'for' that matches an element id
Option::None => ctx.with_encapsulating_label(Some(element)) // 'for' doesn't match
}
Option::None => ctx.with_encapsulating_label(Some(element))
}
} else {
ctx
}
for child in element.children {
match child {
@html.Element(child_elem) => {
let child_node = build_node(child_elem, child_ctx)
// Don't add generic nodes with no name and no children
if !is_empty_generic(child_node) {
children.push(child_node)
}
}
@html.Text(text) => {
// Text nodes become static text
let trimmed = text.trim().to_owned()
if !trimmed.is_empty() {
let text_node = AccessibilityNode::{
id: ctx.next_uid(),
role: Generic,
name: Some(trimmed),
description: Option::None,
level: Option::None,
states: [],
properties: {},
bounds: Option::None,
children: [],
focusable: false,
tabindex: Option::None,
source_id: Option::None,
href: Option::None,
tag_name: Option::None,
selector: Option::None,
visible: true,
ref_id: Some(ctx.next_ref()),
input_type: Option::None,
placeholder: Option::None,
input_value: Option::None,
required: false,
text: Option::None,
options: [],
labelled_by: Option::None,
}
children.push(text_node)
}
}
}
}
children
}
// =============================================================================
// Element Analysis Helpers
// =============================================================================
///|
/// Check if element has any source for accessible name
fn has_accessible_name_source(element : @html.Element) -> Bool {
// aria-label
match element.attributes.get("aria-label") {
Some(label) if !label.is_empty() => return true
_ => ()
}
// aria-labelledby
match element.attributes.get("aria-labelledby") {
Some(ids) if !ids.is_empty() => return true
_ => ()
}
// title
match element.attributes.get("title") {
Some(title) if !title.is_empty() => return true
_ => ()
}
false
}
///|
/// Check if element should be skipped in accessibility tree
fn should_skip_element(element : @html.Element, role : Role) -> Bool {
let tag = element.tag.to_lower()
// Skip script, style, template, head elements
match tag {
"script" | "style" | "template" | "head" | "meta" | "link" | "noscript" =>
return true
_ => ()
}
// Skip elements with role="presentation" or role="none"
match role {
Presentation | None => return true
_ => ()
}
// Skip aria-hidden elements
match element.attributes.get("aria-hidden") {
Some("true") => return true
_ => ()
}
// Skip hidden elements
if element.attributes.contains("hidden") {
return true
}
false
}
///|
/// Check if node is an empty generic that should be pruned
fn is_empty_generic(node : AccessibilityNode) -> Bool {
match node.role {
Generic => node.name is None && node.children.is_empty() && !node.focusable
_ => false
}
}
// =============================================================================
// AI Scraping Helpers
// =============================================================================
///|
/// Extract href attribute from link elements
fn extract_href(element : @html.Element) -> String? {
let tag = element.tag.to_lower()
match tag {
"a" => element.attributes.get("href")
"area" => element.attributes.get("href")
"link" => element.attributes.get("href")
_ => Option::None
}
}
///|
/// Build a CSS selector for an element
fn build_selector(element : @html.Element) -> String? {
let tag = element.tag.to_lower()
let buf = StringBuilder::new()
buf.write_string(tag)
// Add id if present (most specific)
match element.id {
Some(id) if !id.is_empty() => {
buf.write_string("#")
buf.write_string(id)
return Some(buf.to_string())
}
_ => ()
}
// Add classes if present
if !element.classes.is_empty() {
for cls in element.classes {
buf.write_string(".")
buf.write_string(cls)
}
}
Some(buf.to_string())
}
///|
/// Check if element tag represents visible content
/// Returns false for style, script, meta, and head content
fn is_visible_element(tag : String) -> Bool {
let lower = tag.to_lower()
match lower {
"script"
| "style"
| "meta"
| "link"
| "head"
| "title"
| "noscript"
| "template" => false
_ => true
}
}
///|
/// Extract input type from form elements
fn extract_input_type(element : @html.Element) -> String? {
let tag = element.tag.to_lower()
match tag {
"input" =>
match element.attributes.get("type") {
Some(t) => Some(t)
None => Some("text") // default input type
}
"textarea" => Some("textarea")
"select" => Some("select")
"button" =>
match element.attributes.get("type") {
Some(t) => Some(t)
None => Some("submit") // default button type
}
_ => Option::None
}
}
///|
/// Extract placeholder text from form inputs
fn extract_placeholder(element : @html.Element) -> String? {
element.attributes.get("placeholder")
}
///|
/// Extract current value from form inputs
fn extract_input_value(element : @html.Element) -> String? {
let tag = element.tag.to_lower()
match tag {
"input" => element.attributes.get("value")
"textarea" => element.attributes.get("value")
"select" => element.attributes.get("value")
_ => Option::None
}
}
///|
/// Check if form field is required
fn is_required(element : @html.Element) -> Bool {
element.attributes.contains("required") ||
element.attributes.get("aria-required") == Some("true")
}
///|
/// Extract options from select element
fn extract_select_options(element : @html.Element) -> Array[SelectOption] {
let options : Array[SelectOption] = []
let tag = element.tag.to_lower()
if tag != "select" {
return options
}
for child in element.children {
match child {
@html.Element(option_elem) => {
let option_tag = option_elem.tag.to_lower()
if option_tag == "option" {
let value = option_elem.attributes.get("value").unwrap_or("")
let label = get_element_text_content(option_elem)
let selected = option_elem.attributes.contains("selected")
options.push({ value, label, selected })
} else if option_tag == "optgroup" {
// Handle optgroup by extracting its options
for optgroup_child in option_elem.children {
match optgroup_child {
@html.Element(opt) =>
if opt.tag.to_lower() == "option" {
let value = opt.attributes.get("value").unwrap_or("")
let label = get_element_text_content(opt)
let selected = opt.attributes.contains("selected")
options.push({ value, label, selected })
}
_ => ()
}
}
}
}
_ => ()
}
}
options
}
///|
/// Get text content from an element (recursively)
fn get_element_text_content(element : @html.Element) -> String {
let buf = StringBuilder::new()
collect_text_content(element, buf)
buf.to_string().trim().to_owned()
}
///|
/// Recursively collect text content from element
fn collect_text_content(element : @html.Element, buf : StringBuilder) -> Unit {
for child in element.children {
match child {
@html.Text(text) => {
let trimmed = text.trim().to_owned()
if !trimmed.is_empty() {
if buf.to_string().length() > 0 {
buf.write_string(" ")
}
buf.write_string(trimmed)
}
}
@html.Element(child_elem) =>
// Skip non-visible elements
if is_visible_element(child_elem.tag) {
collect_text_content(child_elem, buf)
}
}
}
}
///|
/// Find the ref of the labelling element for a form input
fn find_labelling_ref(
element : @html.Element,
ctx : TreeBuilderContext,
) -> String? {
// Check aria-labelledby first
match element.attributes.get("aria-labelledby") {
Some(label_id) if !label_id.is_empty() => {
// The value might be a space-separated list of IDs, take the first
let parts : Array[String] = label_id
.split(" ")
.map(fn(s) { s.to_owned() })
.collect()
if parts.length() > 0 {
return Some(parts[0])
}
}
_ => ()
}
// Check if there's a label with 'for' attribute matching this element's id
match element.id {
Some(elem_id) =>
match ctx.label_map.get(elem_id) {
Some(_) =>
// Return the element id as a hint (the ref will be computed later)
return Some("label-for-" + elem_id)
_ => ()
}
_ => ()
}
Option::None
}
///|
/// Compute states for an element
fn compute_states(element : @html.Element) -> Array[State] {
let states : Array[State] = []
// Disabled state
if element.attributes.contains("disabled") {
states.push(Disabled)
}
match element.attributes.get("aria-disabled") {
Some("true") => states.push(Disabled)
_ => ()
}
// Checked state
if element.attributes.contains("checked") {
states.push(Checked)
}
match element.attributes.get("aria-checked") {
Some("true") => states.push(Checked)
Some("mixed") => states.push(CheckedMixed)
_ => ()
}
// Expanded state
match element.attributes.get("aria-expanded") {
Some("true") => states.push(Expanded)
_ => ()
}
// Selected state
if element.attributes.contains("selected") {
states.push(Selected)
}
match element.attributes.get("aria-selected") {
Some("true") => states.push(Selected)
_ => ()
}
// Pressed state
match element.attributes.get("aria-pressed") {
Some("true") => states.push(Pressed)
Some("mixed") => states.push(PressedMixed)
_ => ()
}
// Hidden state
match element.attributes.get("aria-hidden") {
Some("true") => states.push(Hidden)
_ => ()
}
// Busy state
match element.attributes.get("aria-busy") {
Some("true") => states.push(Busy)
_ => ()
}
// Invalid state
match element.attributes.get("aria-invalid") {
Some("true") => states.push(Invalid)
_ => ()
}
states
}
///|
/// Check if element is focusable
fn is_focusable(element : @html.Element, role : Role) -> Bool {
let tag = element.tag.to_lower()
// Elements that are never directly tab-focusable
// (they may be focusable within their parent context, but not via Tab)
match tag {
"option" | "optgroup" => return false
_ => ()
}
// Check tabindex
match element.attributes.get("tabindex") {
Some(tabindex) => {
let idx = @string.parse_int(tabindex) catch { _ => return false }
if idx >= 0 {
return true
}
// tabindex="-1" means programmatically focusable but not via tab
return false
}
Option::None => ()
}
// Inherently focusable elements
match tag {
"a" => if element.attributes.contains("href") { return true }
"button" => return !element.attributes.contains("disabled")
"input" =>
if element.attributes.get("type") != Some("hidden") {
return !element.attributes.contains("disabled")
}
"select" | "textarea" => return !element.attributes.contains("disabled")
"summary" => return true
_ => ()
}
// Check if role is inherently focusable
role_is_inherently_focusable(role) && !element.attributes.contains("disabled")
}
///|
/// Get tabindex value from element
/// Returns None if no tabindex attribute, Some(n) otherwise
fn get_tabindex(element : @html.Element) -> Int? {
match element.attributes.get("tabindex") {
Some(tabindex_str) => {
let idx = @string.parse_int(tabindex_str) catch { _ => return None }
Some(idx)
}
Option::None => None
}
}
// =============================================================================
// Layout Integration
// =============================================================================
///|
/// Attach layout bounds to accessibility nodes
/// Takes a mapping from element id to bounds
pub fn attach_bounds(
tree : AccessibilityTree,
bounds_map : Map[String, Bounds],
) -> AccessibilityTree {
let new_root = attach_bounds_recursive(tree.root, bounds_map)
AccessibilityTree::new(new_root)
}
///|
fn attach_bounds_recursive(
node : AccessibilityNode,
bounds_map : Map[String, Bounds],
) -> AccessibilityNode {
// Attach bounds if available
let new_bounds : Bounds? = match node.source_id {
Some(id) =>
match bounds_map.get(id) {
Some(b) => Some(b)
Option::None => node.bounds
}
Option::None => node.bounds
}
// Process children
let new_children : Array[AccessibilityNode] = []
for child in node.children {
new_children.push(attach_bounds_recursive(child, bounds_map))
}
{ ..node, bounds: new_bounds, children: new_children }
}
// =============================================================================
// Tree Queries
// =============================================================================
///|
/// Find all interactive elements in the tree
pub fn find_interactive(tree : AccessibilityTree) -> Array[AccessibilityNode] {
let result : Array[AccessibilityNode] = []
find_interactive_recursive(tree.root, result)
result
}
///|
fn find_interactive_recursive(
node : AccessibilityNode,
result : Array[AccessibilityNode],
) -> Unit {
// Check if this node is interactive
if is_interactive_role(node.role) || node.focusable {
result.push(node)
}
// Recurse into children
for child in node.children {
find_interactive_recursive(child, result)
}
}
///|
fn is_interactive_role(role : Role) -> Bool {
match role {
Button
| Checkbox
| Combobox
| Link
| Listbox
| Menu
| MenuBar
| MenuItem
| MenuItemCheckbox
| MenuItemRadio
| Option
| Radio
| Scrollbar
| SearchBox
| Slider
| SpinButton
| Switch
| Tab
| Textbox
| Tree
| TreeItem => true
_ => false
}
}
///|
/// Find all landmarks in the tree
pub fn find_landmarks(tree : AccessibilityTree) -> Array[AccessibilityNode] {
let result : Array[AccessibilityNode] = []
find_landmarks_recursive(tree.root, result)
result
}
///|
fn find_landmarks_recursive(
node : AccessibilityNode,
result : Array[AccessibilityNode],
) -> Unit {
if role_is_landmark(node.role) {
result.push(node)
}
for child in node.children {
find_landmarks_recursive(child, result)
}
}
///|
/// Find all headings in the tree (returns in document order)
pub fn find_headings(tree : AccessibilityTree) -> Array[AccessibilityNode] {
let result : Array[AccessibilityNode] = []
find_headings_recursive(tree.root, result)
result
}
///|
fn find_headings_recursive(
node : AccessibilityNode,
result : Array[AccessibilityNode],
) -> Unit {
match node.role {
Heading => result.push(node)
_ => ()
}
for child in node.children {
find_headings_recursive(child, result)
}
}
// =============================================================================
// LayoutTree Integration
// =============================================================================
//
// The `LayoutTree`-flavored helpers (extract_bounds_from_layout,
// build_accessibility_tree_with_layout) live in
// `dom/layout/aom_bridge/` so this package does not need a layout dep.
// Consumers using the renderer's `@core.Layout` output should call
// `build_accessibility_tree_with_node_layout` below.
///|
/// Extract bounds from @layout_types.Layout (renderer's layout result)
/// This extracts proper bounds after CSS has been applied
pub fn extract_bounds_from_node_layout(
layout : @layout_types.Layout,
) -> Map[String, Bounds] {
let bounds_map : Map[String, Bounds] = {}
extract_node_layout_bounds_recursive(layout, bounds_map)
bounds_map
}
///|
fn extract_node_layout_bounds_recursive(
layout : @layout_types.Layout,
bounds_map : Map[String, Bounds],
) -> Unit {
// Add bounds for this node if it has a non-empty id
// The renderer uses format "tag#id" or "tag.class" or "tag"
// We need to extract just the id part for matching with AOM source_id
if !layout.id.is_empty() {
let id = extract_element_id(layout.id)
if !id.is_empty() {
bounds_map[id] = Bounds::new(
layout.x,
layout.y,
layout.width,
layout.height,
)
}
}
// Recurse into children
for child in layout.children {
extract_node_layout_bounds_recursive(child, bounds_map)
}
}
///|
/// Extract element ID from renderer's node ID format
/// Renderer uses: "tag#id", "tag.class", or "tag"
/// Returns: just the id part, or empty string if no ID
fn extract_element_id(node_id : String) -> String {
// Look for # which indicates an ID
match node_id.find("#") {
Some(idx) => node_id.unsafe_substring(start=idx + 1, end=node_id.length())
None => ""
}
}
///|
/// Build AccessibilityTree from HTML and attach layout bounds from @layout_types.Layout
/// This is the integration function for combining a11y with renderer's layout
pub fn build_accessibility_tree_with_node_layout(
doc : @html.Document,
layout : @layout_types.Layout,
) -> AccessibilityTree {
// Build accessibility tree
let a11y_tree = build_accessibility_tree(doc)
// Extract bounds from node layout
let bounds_map = extract_bounds_from_node_layout(layout)
// Attach bounds to accessibility nodes
attach_bounds(a11y_tree, bounds_map)
}
// =============================================================================
// Lightweight Accessibility Tree Builder
// =============================================================================
// For large HTML files, we skip CSS parsing and layout to save memory.
// This produces an AOM tree without bounds, suitable for --json/--aom modes.
///|
/// Lightweight context without stylesheets
priv struct LightweightContext {
element_map : Map[String, @html.Element]
label_map : Map[String, @html.Element]
encapsulating_label : @html.Element?
mut ref_counter : Int
}
///|
fn LightweightContext::new() -> LightweightContext {
{ element_map: {}, label_map: {}, encapsulating_label: None, ref_counter: 1 }
}
///|
fn LightweightContext::next_ref(self : LightweightContext) -> String {
let ref_num = self.ref_counter
self.ref_counter += 1
"ref_\{ref_num}"
}
///|
fn LightweightContext::with_encapsulating_label(
self : LightweightContext,
label : @html.Element?,
) -> LightweightContext {
{ ..self, encapsulating_label: label }
}
///|
/// Index elements for lightweight build
fn lightweight_index_elements(
element : @html.Element,
ctx : LightweightContext,
) -> Unit {
match element.id {
Some(id) => ctx.element_map[id] = element
None => ()
}
if element.tag.to_lower() == "label" {
match element.attributes.get("for") {
Some(for_id) => ctx.label_map[for_id] = element
None => ()
}
}
for child in element.children {
match child {
@html.Node::Element(child_elem) =>
lightweight_index_elements(child_elem, ctx)
@html.Node::Text(_) => ()
}
}
}
///|
/// Build a lightweight accessibility node (no pseudo-elements, no bounds)
fn lightweight_build_node(
element : @html.Element,
ctx : LightweightContext,
) -> AccessibilityNode {
let tag = element.tag.to_lower()
let role_ctx = RoleMappingContext::default()
let role = compute_role(element, role_ctx)
let ref_id = ctx.next_ref()
// Skip script/style/template elements entirely (no text content)
if tag == "script" || tag == "style" || tag == "template" || tag == "noscript" {
let node = AccessibilityNode::new(ref_id, Role::Generic)
return { ..node, tag_name: Some(tag), visible: false, children: [] }
}
// Get accessible name (simplified - no pseudo-element content)
let name = lightweight_get_name(element, ctx)
// Get source element id
let source_id = element.id
// Build CSS selector for this element
let selector = build_selector(element)
// Determine if element should be visible
let is_hidden = element.attributes.get("hidden") is Some(_) ||
element.attributes.get("aria-hidden") == Some("true")
let visible = !is_hidden
// Handle labels for form inputs
let ctx = if tag == "label" {
ctx.with_encapsulating_label(Some(element))
} else {
ctx
}
// Build children
let children : Array[AccessibilityNode] = []
for child in element.children {
match child {
@html.Node::Element(child_elem) => {
let child_node = lightweight_build_node(child_elem, ctx)
children.push(child_node)
}
@html.Node::Text(text) => {
let trimmed = text.trim().to_owned()
if !trimmed.is_empty() {
let text_node = AccessibilityNode::new(ctx.next_ref(), Role::Generic)
let text_node = { ..text_node, name: Some(trimmed) }
children.push(text_node)
}
}
}
}
// Get form data
let (input_value, options) = lightweight_get_form_data(element)
// Create base node
let node = AccessibilityNode::new(ref_id, role)
{
..node,
name,
source_id,
tag_name: Some(tag),
selector,
visible,
ref_id: Some(ref_id),
children,
input_value,
options: options.unwrap_or([]),
href: element.attributes.get("href"),
}
}
///|
/// Get accessible name without pseudo-element content
fn lightweight_get_name(
element : @html.Element,
ctx : LightweightContext,
) -> String? {
let tag = element.tag.to_lower()
// Check aria-label first
match element.attributes.get("aria-label") {
Some(label) if !label.is_empty() => return Some(label)
_ => ()
}
// Check aria-labelledby
match element.attributes.get("aria-labelledby") {
Some(ids) => {
let names : Array[String] = []
for id in ids.split(" ") {
let id_str = id.to_owned().trim().to_owned()
match ctx.element_map.get(id_str) {
Some(elem) => {
let text = lightweight_extract_text(elem)
if !text.is_empty() {
names.push(text)
}
}
None => ()
}
}
if names.length() > 0 {
return Some(
names
.iter()
.fold(init="", fn(acc, s) {
if acc.is_empty() {
s
} else {
acc + " " + s
}
}),
)
}
}
None => ()
}
// Check for associated label
match element.id {
Some(id) =>
match ctx.label_map.get(id) {
Some(label_elem) => {
let text = lightweight_extract_text(label_elem)
if !text.is_empty() {
return Some(text)
}
}
None => ()
}
None => ()
}
// Check encapsulating label
match ctx.encapsulating_label {
Some(label_elem) => {
let text = lightweight_extract_text(label_elem)
if !text.is_empty() {
return Some(text)
}
}
None => ()
}
// Tag-specific names
match tag {
"img" | "area" =>
match element.attributes.get("alt") {
Some(alt) if !alt.is_empty() => return Some(alt)
_ => ()
}
"input" =>
match element.attributes.get("type") {
Some("submit") =>
return Some(element.attributes.get("value").unwrap_or("Submit"))
Some("reset") =>
return Some(element.attributes.get("value").unwrap_or("Reset"))
Some("button") => return element.attributes.get("value")
_ => ()
}
"a"
| "button"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "label"
| "legend"
| "caption"
| "figcaption"
| "option"
| "th"
| "td"
| "li" => {
let text = lightweight_extract_text(element)
if !text.is_empty() {
return Some(text)
}
}
_ => ()
}
// Check title attribute as fallback
match element.attributes.get("title") {
Some(title) if !title.is_empty() => Some(title)
_ => None
}
}
///|
/// Extract text content without pseudo-elements
fn lightweight_extract_text(element : @html.Element) -> String {
let buf = StringBuilder::new()
for child in element.children {
match child {
@html.Node::Text(text) => {
if buf.to_string().length() > 0 {
buf.write_char(' ')
}
buf.write_string(text.trim().to_owned())
}
@html.Node::Element(child_elem) => {
let child_text = lightweight_extract_text(child_elem)
if !child_text.is_empty() {
if buf.to_string().length() > 0 {
buf.write_char(' ')
}
buf.write_string(child_text)
}
}
}
}
buf.to_string().trim().to_owned()
}
///|
/// Get form data for lightweight build
fn lightweight_get_form_data(
element : @html.Element,
) -> (String?, Array[SelectOption]?) {
let tag = element.tag.to_lower()
match tag {
"input" => {
let input_type = element.attributes.get("type").unwrap_or("text")
match input_type {
"text" | "email" | "tel" | "url" | "search" | "password" | "number" =>
(element.attributes.get("value"), None)
"checkbox" | "radio" => {
let checked = element.attributes.get("checked") is Some(_)
(Some(if checked { "true" } else { "false" }), None)
}
_ => (None, None)
}
}
"textarea" => (Some(lightweight_extract_text(element)), None)
"select" => {
let options : Array[SelectOption] = []
lightweight_collect_options(element, options)
let selected = options.iter().find_first(fn(o) { o.selected })
let value = match selected {
Some(opt) => Some(opt.label)
None => if options.length() > 0 { Some(options[0].label) } else { None }
}
(value, Some(options))
}
_ => (None, None)
}
}
///|
/// Collect options from select element
fn lightweight_collect_options(
element : @html.Element,
options : Array[SelectOption],
) -> Unit {
for child in element.children {
match child {
@html.Node::Element(child_elem) => {
let child_tag = child_elem.tag.to_lower()
if child_tag == "option" {
let label = lightweight_extract_text(child_elem)
let value = child_elem.attributes.get("value").unwrap_or(label)
let selected = child_elem.attributes.get("selected") is Some(_)
options.push({ value, label, selected })
} else if child_tag == "optgroup" {
lightweight_collect_options(child_elem, options)
}
}
_ => ()
}
}
}
///|
/// Build lightweight accessibility tree (no CSS, no layout, no bounds)
/// This is much faster and uses less memory for large HTML files.
pub fn build_accessibility_tree_lightweight(
doc : @html.Document,
) -> AccessibilityTree {
let ctx = LightweightContext::new()
lightweight_index_elements(doc.root, ctx)
let root_node = lightweight_build_node(doc.root, ctx)
AccessibilityTree::new(root_node)
}