From c66ce638ed7ffd4f59db30a17054c817c5bdf7e2 Mon Sep 17 00:00:00 2001 From: rahuld109 Date: Thu, 22 Jan 2026 14:57:23 -0800 Subject: [PATCH 01/11] refactor(biome_html_analyze): consolidate a11y helper functions Extracts duplicated a11y helper logic into shared utilities in `a11y.rs`: - `is_aria_hidden_true()`: strict aria-hidden="true" check - `get_truthy_aria_hidden_attribute()`: returns attribute if truthy - `has_non_empty_attribute()`: checks non-empty trimmed attribute value - `has_accessible_name()`: checks aria-label or title attributes Added type-specific variants to avoid cloning in recursive functions: - `html_element_has_truthy_aria_hidden()` - `html_self_closing_element_has_truthy_aria_hidden()` - `html_self_closing_element_has_accessible_name()` - `html_self_closing_element_has_non_empty_attribute()` Updated rules to use shared helpers: - use_alt_text - use_anchor_content - use_html_lang - use_iframe_title - no_svg_without_title - no_redundant_alt --- crates/biome_html_analyze/src/a11y.rs | 116 ++++++++++++++++++ .../src/lint/a11y/no_redundant_alt.rs | 23 +--- .../src/lint/a11y/no_svg_without_title.rs | 11 +- .../src/lint/a11y/use_alt_text.rs | 42 +------ .../src/lint/a11y/use_anchor_content.rs | 115 ++--------------- .../src/lint/a11y/use_html_lang.rs | 9 +- .../src/lint/a11y/use_iframe_title.rs | 9 +- 7 files changed, 147 insertions(+), 178 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 6629417b88e5..2c4391a9afd5 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -1,4 +1,5 @@ use biome_html_syntax::element_ext::AnyHtmlTagElement; +use biome_html_syntax::{AnyHtmlElement, HtmlAttribute}; /// Check the element is hidden from screen reader. /// @@ -20,3 +21,118 @@ pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool _ => false, } } + +/// Strict check: returns `true` only when `aria-hidden="true"` (case-sensitive). +/// +/// Ref: +pub(crate) fn is_aria_hidden_true(element: &AnyHtmlElement) -> bool { + element + .find_attribute_by_name("aria-hidden") + .is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| value == "true") + }) +} + +/// Returns the `aria-hidden` attribute if it has a truthy value. +/// +/// Returns `Some` if present and not "false" (case-insensitive), `None` otherwise. +/// Useful for code fixes that need to reference the attribute. +/// +/// Ref: +pub(crate) fn get_truthy_aria_hidden_attribute(element: &AnyHtmlElement) -> Option { + let attribute = element.find_attribute_by_name("aria-hidden")?; + let is_truthy = attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_none_or(|value| !value.eq_ignore_ascii_case("false")); + + if is_truthy { Some(attribute) } else { None } +} + +/// Returns `true` if attribute exists with non-empty trimmed value. +pub(crate) fn has_non_empty_attribute(element: &AnyHtmlElement, name: &str) -> bool { + element.find_attribute_by_name(name).is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }) +} + +/// Returns `true` if element has `aria-label` or `title` with non-empty value. +pub(crate) fn has_accessible_name(element: &AnyHtmlElement) -> bool { + has_non_empty_attribute(element, "aria-label") || has_non_empty_attribute(element, "title") +} + +// Type-specific variants avoid wrapping/cloning for performance in recursive code + +/// Type-specific variant for `HtmlElement`. Checks truthy `aria-hidden`. +pub(crate) fn html_element_has_truthy_aria_hidden( + element: &biome_html_syntax::HtmlElement, +) -> bool { + element + .find_attribute_by_name("aria-hidden") + .is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_none_or(|value| !value.eq_ignore_ascii_case("false")) + }) +} + +/// Type-specific variant for `HtmlSelfClosingElement`. Checks truthy `aria-hidden`. +pub(crate) fn html_self_closing_element_has_truthy_aria_hidden( + element: &biome_html_syntax::HtmlSelfClosingElement, +) -> bool { + element + .find_attribute_by_name("aria-hidden") + .is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_none_or(|value| !value.eq_ignore_ascii_case("false")) + }) +} + +/// Type-specific variant for `HtmlSelfClosingElement`. Checks accessible name. +pub(crate) fn html_self_closing_element_has_accessible_name( + element: &biome_html_syntax::HtmlSelfClosingElement, +) -> bool { + let has_aria_label = element.find_attribute_by_name("aria-label").is_some_and(|attr| { + attr.initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }); + + let has_title = element.find_attribute_by_name("title").is_some_and(|attr| { + attr.initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }); + + has_aria_label || has_title +} + +/// Type-specific variant for `HtmlSelfClosingElement`. Checks non-empty attribute. +pub(crate) fn html_self_closing_element_has_non_empty_attribute( + element: &biome_html_syntax::HtmlSelfClosingElement, + name: &str, +) -> bool { + element.find_attribute_by_name(name).is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }) +} \ No newline at end of file diff --git a/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs index 3815042f473b..3b44c3098274 100644 --- a/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs +++ b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs @@ -63,26 +63,9 @@ impl Rule for NoRedundantAlt { return None; } - let aria_hidden_attribute = node.find_attribute_by_name("aria-hidden"); - if let Some(aria_hidden) = aria_hidden_attribute { - let is_false = match aria_hidden.initializer()?.value().ok()? { - AnyHtmlAttributeInitializer::HtmlSingleTextExpression(aria_hidden) => { - aria_hidden - .expression() - .ok()? - .html_literal_token() - .ok()? - .text_trimmed() - == "false" - } - AnyHtmlAttributeInitializer::HtmlString(aria_hidden) => { - aria_hidden.inner_string_text().ok()?.text() == "false" - } - }; - - if !is_false { - return None; - } + // If aria-hidden is truthy (present and not "false"), skip the check + if node.has_truthy_attribute("aria-hidden") { + return None; } let alt = node diff --git a/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs b/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs index 0751b7c82a90..319b4593e680 100644 --- a/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs +++ b/crates/biome_html_analyze/src/lint/a11y/no_svg_without_title.rs @@ -6,6 +6,8 @@ use biome_rowan::AstNode; use biome_rule_options::no_svg_without_title::NoSvgWithoutTitleOptions; use biome_string_case::StrLikeExtension; +use crate::a11y::is_aria_hidden_true; + const NAME_REQUIRED_ROLES: &[&str] = &["img", "image", "graphics-document", "graphics-symbol"]; declare_lint_rule! { @@ -135,13 +137,8 @@ impl Rule for NoSvgWithoutTitle { return None; } - if let Some(aria_hidden_attr) = node.find_attribute_by_name("aria-hidden") - && let Some(attr_static_val) = aria_hidden_attr.initializer() - { - let attr_text = attr_static_val.value().ok()?.string_value()?; - if attr_text == "true" { - return None; - } + if is_aria_hidden_true(node) { + return None; } // Checks if a `svg` element has a valid `title` element in a childlist diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs index e0544bb6ebf3..3d79911abf65 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs @@ -7,6 +7,8 @@ use biome_html_syntax::{AnyHtmlElement, HtmlFileSource}; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_alt_text::UseAltTextOptions; +use crate::a11y::{has_non_empty_attribute, is_aria_hidden_true}; + declare_lint_rule! { /// Enforce that all elements that require alternative text have meaningful information to relay back to the end user. /// @@ -118,9 +120,9 @@ impl Rule for UseAltText { let is_html_file = file_source.is_html(); let has_alt = has_valid_alt_text(element); - let has_aria_label = has_valid_label(element, "aria-label"); - let has_aria_labelledby = has_valid_label(element, "aria-labelledby"); - let aria_hidden = is_aria_hidden(element); + let has_aria_label = has_non_empty_attribute(element, "aria-label"); + let has_aria_labelledby = has_non_empty_attribute(element, "aria-labelledby"); + let aria_hidden = is_aria_hidden_true(element); let name_matches = |name: &str| -> bool { if is_html_file { @@ -131,7 +133,7 @@ impl Rule for UseAltText { }; if name_matches("object") { - let has_title = has_valid_label(element, "title"); + let has_title = has_non_empty_attribute(element, "title"); if !has_title && !has_aria_label && !has_aria_labelledby && !aria_hidden { // For object elements, check if it has accessible child content @@ -208,35 +210,3 @@ fn has_valid_alt_text(element: &AnyHtmlElement) -> bool { element.find_attribute_by_name("alt").is_some() } -/// Check if the element has a valid label attribute (aria-label, aria-labelledby, or title) -fn has_valid_label(element: &AnyHtmlElement, name_to_lookup: &str) -> bool { - element - .find_attribute_by_name(name_to_lookup) - .is_some_and(|attribute| { - // If no initializer, the attribute is present but empty - not valid for labels - let Some(initializer) = attribute.initializer() else { - return false; - }; - - // Check if the value is not empty - initializer - .value() - .ok() - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }) -} - -/// Check if the element has aria-hidden="true" -fn is_aria_hidden(element: &AnyHtmlElement) -> bool { - element - .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - // Only aria-hidden="true" means hidden (must have initializer with value "true") - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| value == "true") - }) -} diff --git a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs index ae78ede5fffe..c1dc9da9ec13 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs @@ -9,6 +9,12 @@ use biome_html_syntax::{ use biome_rowan::{AstNode, BatchMutationExt}; use crate::HtmlRuleAction; +use crate::a11y::{ + get_truthy_aria_hidden_attribute, has_accessible_name, + html_element_has_truthy_aria_hidden, html_self_closing_element_has_accessible_name, + html_self_closing_element_has_non_empty_attribute, + html_self_closing_element_has_truthy_aria_hidden, +}; declare_lint_rule! { /// Enforce that anchors have content and that the content is accessible to screen readers. @@ -111,7 +117,7 @@ impl Rule for UseAnchorContent { } // Check if the anchor itself has aria-hidden attribute - if let Some(aria_hidden_attr) = get_truthy_aria_hidden(node) { + if let Some(aria_hidden_attr) = get_truthy_aria_hidden_attribute(node) { return Some(UseAnchorContentState { aria_hidden_attribute: Some(aria_hidden_attr), }); @@ -182,127 +188,33 @@ impl Rule for UseAnchorContent { } } -/// Returns the aria-hidden attribute if it has a truthy value. -fn get_truthy_aria_hidden(node: &AnyHtmlElement) -> Option { - let attribute = node.find_attribute_by_name("aria-hidden")?; - let is_truthy = attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")); - - if is_truthy { Some(attribute) } else { None } -} - -/// Checks if the element has an accessible name via aria-label or title attribute. -fn has_accessible_name(node: &AnyHtmlElement) -> bool { - // Check aria-label attribute - if let Some(attr) = node.find_attribute_by_name("aria-label") - && attr - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|s| !s.trim().is_empty()) - { - return true; - } - - // Check title attribute - if let Some(attr) = node.find_attribute_by_name("title") - && attr - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|s| !s.trim().is_empty()) - { - return true; - } - - false -} - -/// Checks if the given `HtmlElementList` has accessible content. -/// Accessible content is either: -/// - Non-empty text content -/// - Child elements that don't have `aria-hidden="true"` +/// Checks if `HtmlElementList` contains accessible content (non-empty text or visible elements). fn has_accessible_content(html_child_list: &HtmlElementList) -> bool { html_child_list.into_iter().any(|child| match &child { AnyHtmlElement::AnyHtmlContent(content) => is_accessible_text_content(content), AnyHtmlElement::HtmlElement(element) => { - // Check if this child element has aria-hidden - let has_aria_hidden = - element - .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")) - }); - - if has_aria_hidden { - // This element is hidden, check if there's other accessible content at this level + if html_element_has_truthy_aria_hidden(element) { false } else { - // Element is not hidden, check if it has accessible content recursively has_accessible_content(&element.children()) } } AnyHtmlElement::HtmlSelfClosingElement(element) => { - // Check if element is hidden with aria-hidden - let has_aria_hidden = - element - .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")) - }); - if has_aria_hidden { + if html_self_closing_element_has_truthy_aria_hidden(element) { return false; } - // Check for explicit accessible name via aria-label or title - let has_aria_label = element - .find_attribute_by_name("aria-label") - .is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|s| !s.trim().is_empty()) - }); - if has_aria_label { - return true; - } - - let has_title = element.find_attribute_by_name("title").is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|s| !s.trim().is_empty()) - }); - if has_title { + if html_self_closing_element_has_accessible_name(element) { return true; } - // Check tag-specific accessible content let tag_name = element.name().ok().and_then(|n| n.value_token().ok()); let tag_text = tag_name.as_ref().map(|t| t.text_trimmed()); match tag_text { - // requires non-empty alt attribute Some(name) if name.eq_ignore_ascii_case("img") => { - element.find_attribute_by_name("alt").is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|s| !s.trim().is_empty()) - }) + html_self_closing_element_has_non_empty_attribute(element, "alt") } - // Void elements without meaningful content are not accessible Some(name) if name.eq_ignore_ascii_case("br") || name.eq_ignore_ascii_case("hr") @@ -314,7 +226,6 @@ fn has_accessible_content(html_child_list: &HtmlElementList) -> bool { { false } - // is not accessible, other inputs may be Some(name) if name.eq_ignore_ascii_case("input") => { let is_hidden = element.find_attribute_by_name("type").is_some_and(|attr| { attr.initializer() @@ -324,11 +235,9 @@ fn has_accessible_content(html_child_list: &HtmlElementList) -> bool { }); !is_hidden } - // Other self-closing elements without explicit accessible name are not accessible _ => false, } } - // Bogus elements and CDATA sections - treat as potentially accessible to avoid false positives AnyHtmlElement::HtmlBogusElement(_) | AnyHtmlElement::HtmlCdataSection(_) => true, }) } diff --git a/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs b/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs index 3fc06d74eb9d..706cf3b601fc 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_html_lang.rs @@ -7,6 +7,8 @@ use biome_html_syntax::AnyHtmlElement; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_html_lang::UseHtmlLangOptions; +use crate::a11y::has_non_empty_attribute; + declare_lint_rule! { /// Enforce that `html` element has `lang` attribute. /// @@ -55,12 +57,7 @@ impl Rule for UseHtmlLang { return None; } - if let Some(lang_attribute) = element.find_attribute_by_name("lang") - && let Some(initializer) = lang_attribute.initializer() - && let Ok(value) = initializer.value() - && let Some(value) = value.string_value() - && !value.trim_ascii().is_empty() - { + if has_non_empty_attribute(element, "lang") { return None; } diff --git a/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs b/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs index 120baa95caf0..bb38b7b7f215 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_iframe_title.rs @@ -7,6 +7,8 @@ use biome_html_syntax::AnyHtmlElement; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_iframe_title::UseIframeTitleOptions; +use crate::a11y::has_non_empty_attribute; + declare_lint_rule! { /// Enforces the usage of the attribute `title` for the element `iframe`. /// @@ -63,12 +65,7 @@ impl Rule for UseIframeTitle { return None; } - if let Some(title_attribute) = element.find_attribute_by_name("title") - && let Some(initializer) = title_attribute.initializer() - && let Ok(value) = initializer.value() - && let Some(value) = value.string_value() - && !value.trim_ascii().is_empty() - { + if has_non_empty_attribute(element, "title") { return None; } From b345567e1de2f5aea26a94b20aac59d889366a76 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 23:09:41 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- crates/biome_html_analyze/src/a11y.rs | 48 +++++++++++-------- .../src/lint/a11y/use_alt_text.rs | 1 - .../src/lint/a11y/use_anchor_content.rs | 4 +- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 2c4391a9afd5..91cdefdc00da 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -56,13 +56,15 @@ pub(crate) fn get_truthy_aria_hidden_attribute(element: &AnyHtmlElement) -> Opti /// Returns `true` if attribute exists with non-empty trimmed value. pub(crate) fn has_non_empty_attribute(element: &AnyHtmlElement, name: &str) -> bool { - element.find_attribute_by_name(name).is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }) + element + .find_attribute_by_name(name) + .is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }) } /// Returns `true` if element has `aria-label` or `title` with non-empty value. @@ -106,12 +108,14 @@ pub(crate) fn html_self_closing_element_has_truthy_aria_hidden( pub(crate) fn html_self_closing_element_has_accessible_name( element: &biome_html_syntax::HtmlSelfClosingElement, ) -> bool { - let has_aria_label = element.find_attribute_by_name("aria-label").is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }); + let has_aria_label = element + .find_attribute_by_name("aria-label") + .is_some_and(|attr| { + attr.initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }); let has_title = element.find_attribute_by_name("title").is_some_and(|attr| { attr.initializer() @@ -128,11 +132,13 @@ pub(crate) fn html_self_closing_element_has_non_empty_attribute( element: &biome_html_syntax::HtmlSelfClosingElement, name: &str, ) -> bool { - element.find_attribute_by_name(name).is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }) -} \ No newline at end of file + element + .find_attribute_by_name(name) + .is_some_and(|attribute| { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) + }) +} diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs index 3d79911abf65..260b4a95f3d1 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs @@ -209,4 +209,3 @@ fn has_valid_alt_text(element: &AnyHtmlElement) -> bool { // If there's an initializer with a value, any value is valid element.find_attribute_by_name("alt").is_some() } - diff --git a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs index c1dc9da9ec13..ca6daae001f4 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_anchor_content.rs @@ -10,8 +10,8 @@ use biome_rowan::{AstNode, BatchMutationExt}; use crate::HtmlRuleAction; use crate::a11y::{ - get_truthy_aria_hidden_attribute, has_accessible_name, - html_element_has_truthy_aria_hidden, html_self_closing_element_has_accessible_name, + get_truthy_aria_hidden_attribute, has_accessible_name, html_element_has_truthy_aria_hidden, + html_self_closing_element_has_accessible_name, html_self_closing_element_has_non_empty_attribute, html_self_closing_element_has_truthy_aria_hidden, }; From 02bd109bc9682fb72dba28640f9a5078aa122896 Mon Sep 17 00:00:00 2001 From: rahuld109 Date: Thu, 22 Jan 2026 15:28:00 -0800 Subject: [PATCH 03/11] refactor(biome_html_analyze): extract shared a11y helper logic Extract duplicated attribute-checking patterns into core helpers: - is_truthy_aria_hidden_value() - is_strict_true_value() - has_non_empty_value() All public helpers now delegate to these core functions. --- crates/biome_html_analyze/src/a11y.rs | 110 +++++++++++++------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 91cdefdc00da..3692eb4acbf4 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -1,6 +1,42 @@ use biome_html_syntax::element_ext::AnyHtmlTagElement; use biome_html_syntax::{AnyHtmlElement, HtmlAttribute}; +// ============================================================================ +// Core attribute value helpers (shared logic extracted here) +// ============================================================================ + +/// Checks if an attribute has a truthy `aria-hidden` value. +/// Returns `true` if value is not "false" (case-insensitive) or if no value is provided. +fn is_truthy_aria_hidden_value(attribute: &HtmlAttribute) -> bool { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_none_or(|value| !value.eq_ignore_ascii_case("false")) +} + +/// Checks if an attribute value equals "true" exactly (case-sensitive). +fn is_strict_true_value(attribute: &HtmlAttribute) -> bool { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| value == "true") +} + +/// Checks if an attribute has a non-empty trimmed value. +fn has_non_empty_value(attribute: &HtmlAttribute) -> bool { + attribute + .initializer() + .and_then(|init| init.value().ok()) + .and_then(|value| value.string_value()) + .is_some_and(|value| !value.trim().is_empty()) +} + +// ============================================================================ +// Element-level helpers (use core helpers above) +// ============================================================================ + /// Check the element is hidden from screen reader. /// /// Ref: @@ -28,13 +64,7 @@ pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool pub(crate) fn is_aria_hidden_true(element: &AnyHtmlElement) -> bool { element .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| value == "true") - }) + .is_some_and(|attr| is_strict_true_value(&attr)) } /// Returns the `aria-hidden` attribute if it has a truthy value. @@ -45,26 +75,18 @@ pub(crate) fn is_aria_hidden_true(element: &AnyHtmlElement) -> bool { /// Ref: pub(crate) fn get_truthy_aria_hidden_attribute(element: &AnyHtmlElement) -> Option { let attribute = element.find_attribute_by_name("aria-hidden")?; - let is_truthy = attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")); - - if is_truthy { Some(attribute) } else { None } + if is_truthy_aria_hidden_value(&attribute) { + Some(attribute) + } else { + None + } } /// Returns `true` if attribute exists with non-empty trimmed value. pub(crate) fn has_non_empty_attribute(element: &AnyHtmlElement, name: &str) -> bool { element .find_attribute_by_name(name) - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }) + .is_some_and(|attr| has_non_empty_value(&attr)) } /// Returns `true` if element has `aria-label` or `title` with non-empty value. @@ -72,7 +94,9 @@ pub(crate) fn has_accessible_name(element: &AnyHtmlElement) -> bool { has_non_empty_attribute(element, "aria-label") || has_non_empty_attribute(element, "title") } -// Type-specific variants avoid wrapping/cloning for performance in recursive code +// ============================================================================ +// Type-specific variants (avoid wrapping/cloning in recursive code) +// ============================================================================ /// Type-specific variant for `HtmlElement`. Checks truthy `aria-hidden`. pub(crate) fn html_element_has_truthy_aria_hidden( @@ -80,13 +104,7 @@ pub(crate) fn html_element_has_truthy_aria_hidden( ) -> bool { element .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")) - }) + .is_some_and(|attr| is_truthy_aria_hidden_value(&attr)) } /// Type-specific variant for `HtmlSelfClosingElement`. Checks truthy `aria-hidden`. @@ -95,13 +113,7 @@ pub(crate) fn html_self_closing_element_has_truthy_aria_hidden( ) -> bool { element .find_attribute_by_name("aria-hidden") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")) - }) + .is_some_and(|attr| is_truthy_aria_hidden_value(&attr)) } /// Type-specific variant for `HtmlSelfClosingElement`. Checks accessible name. @@ -110,20 +122,10 @@ pub(crate) fn html_self_closing_element_has_accessible_name( ) -> bool { let has_aria_label = element .find_attribute_by_name("aria-label") - .is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }); - - let has_title = element.find_attribute_by_name("title").is_some_and(|attr| { - attr.initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }); - + .is_some_and(|attr| has_non_empty_value(&attr)); + let has_title = element + .find_attribute_by_name("title") + .is_some_and(|attr| has_non_empty_value(&attr)); has_aria_label || has_title } @@ -134,11 +136,5 @@ pub(crate) fn html_self_closing_element_has_non_empty_attribute( ) -> bool { element .find_attribute_by_name(name) - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) - }) + .is_some_and(|attr| has_non_empty_value(&attr)) } From 8dd95a9c887a7245f01b55d275f4233d9f39dbd4 Mon Sep 17 00:00:00 2001 From: rahuld109 Date: Thu, 22 Jan 2026 16:01:44 -0800 Subject: [PATCH 04/11] refactor(biome_html_analyze): extract core attribute helper as foundation Add `get_attribute_string_value()` as the single source of truth for extracting attribute values. All other helpers now delegate to this fundamental function, eliminating the remaining duplication. Also adds public `attribute_value_equals_ignore_case()` helper and updates `is_hidden_from_screen_reader()` and `has_type_image_attribute()` to use the shared helpers. --- crates/biome_html_analyze/src/a11y.rs | 36 ++++++++++--------- .../src/lint/a11y/use_alt_text.rs | 10 ++---- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 3692eb4acbf4..8e29c8af197f 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -1,36 +1,40 @@ use biome_html_syntax::element_ext::AnyHtmlTagElement; use biome_html_syntax::{AnyHtmlElement, HtmlAttribute}; +use biome_rowan::Text; // ============================================================================ // Core attribute value helpers (shared logic extracted here) // ============================================================================ -/// Checks if an attribute has a truthy `aria-hidden` value. -/// Returns `true` if value is not "false" (case-insensitive) or if no value is provided. -fn is_truthy_aria_hidden_value(attribute: &HtmlAttribute) -> bool { +/// Extracts the string value from an attribute's initializer. +/// This is the fundamental building block for all attribute value checks. +fn get_attribute_string_value(attribute: &HtmlAttribute) -> Option { attribute .initializer() .and_then(|init| init.value().ok()) .and_then(|value| value.string_value()) +} + +/// Checks if an attribute has a truthy `aria-hidden` value. +/// Returns `true` if value is not "false" (case-insensitive) or if no value is provided. +fn is_truthy_aria_hidden_value(attribute: &HtmlAttribute) -> bool { + get_attribute_string_value(attribute) .is_none_or(|value| !value.eq_ignore_ascii_case("false")) } /// Checks if an attribute value equals "true" exactly (case-sensitive). fn is_strict_true_value(attribute: &HtmlAttribute) -> bool { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| value == "true") + get_attribute_string_value(attribute).is_some_and(|value| value == "true") } /// Checks if an attribute has a non-empty trimmed value. fn has_non_empty_value(attribute: &HtmlAttribute) -> bool { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| !value.trim().is_empty()) + get_attribute_string_value(attribute).is_some_and(|value| !value.trim().is_empty()) +} + +/// Checks if an attribute value matches a specific string (case-insensitive). +pub(crate) fn attribute_value_equals_ignore_case(attribute: &HtmlAttribute, expected: &str) -> bool { + get_attribute_string_value(attribute).is_some_and(|value| value.eq_ignore_ascii_case(expected)) } // ============================================================================ @@ -44,16 +48,14 @@ fn has_non_empty_value(attribute: &HtmlAttribute) -> bool { /// - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden /// - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool { - let is_aria_hidden = element.has_truthy_attribute("aria-hidden"); - if is_aria_hidden { + if element.has_truthy_attribute("aria-hidden") { return true; } match element.name_value_token().ok() { Some(name) if name.text_trimmed() == "input" => element .find_attribute_by_name("type") - .and_then(|attribute| attribute.initializer()?.value().ok()?.string_value()) - .is_some_and(|value| value.text() == "hidden"), + .is_some_and(|attr| attribute_value_equals_ignore_case(&attr, "hidden")), _ => false, } } diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs index 260b4a95f3d1..dfef7dec1b72 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs @@ -7,7 +7,7 @@ use biome_html_syntax::{AnyHtmlElement, HtmlFileSource}; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_alt_text::UseAltTextOptions; -use crate::a11y::{has_non_empty_attribute, is_aria_hidden_true}; +use crate::a11y::{attribute_value_equals_ignore_case, has_non_empty_attribute, is_aria_hidden_true}; declare_lint_rule! { /// Enforce that all elements that require alternative text have meaningful information to relay back to the end user. @@ -193,13 +193,7 @@ impl Rule for UseAltText { fn has_type_image_attribute(element: &AnyHtmlElement) -> bool { element .find_attribute_by_name("type") - .is_some_and(|attribute| { - attribute - .initializer() - .and_then(|init| init.value().ok()) - .and_then(|value| value.string_value()) - .is_some_and(|value| value.eq_ignore_ascii_case("image")) - }) + .is_some_and(|attr| attribute_value_equals_ignore_case(&attr, "image")) } /// Check if the element has a valid alt attribute From a40031ed6a6f64b526ba51d5493809cd9bf2449c Mon Sep 17 00:00:00 2001 From: rahuld109 Date: Thu, 22 Jan 2026 16:03:46 -0800 Subject: [PATCH 05/11] fix(biome_html_analyze): add aria-labelledby to accessible name checks Include `aria-labelledby` as a valid accessible name source per ARIA specs in both `has_accessible_name()` and `html_self_closing_element_has_accessible_name()`. --- crates/biome_html_analyze/src/a11y.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 8e29c8af197f..47905b866e57 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -91,9 +91,11 @@ pub(crate) fn has_non_empty_attribute(element: &AnyHtmlElement, name: &str) -> b .is_some_and(|attr| has_non_empty_value(&attr)) } -/// Returns `true` if element has `aria-label` or `title` with non-empty value. +/// Returns `true` if element has `aria-label`, `aria-labelledby`, or `title` with non-empty value. pub(crate) fn has_accessible_name(element: &AnyHtmlElement) -> bool { - has_non_empty_attribute(element, "aria-label") || has_non_empty_attribute(element, "title") + has_non_empty_attribute(element, "aria-label") + || has_non_empty_attribute(element, "aria-labelledby") + || has_non_empty_attribute(element, "title") } // ============================================================================ @@ -125,10 +127,13 @@ pub(crate) fn html_self_closing_element_has_accessible_name( let has_aria_label = element .find_attribute_by_name("aria-label") .is_some_and(|attr| has_non_empty_value(&attr)); + let has_aria_labelledby = element + .find_attribute_by_name("aria-labelledby") + .is_some_and(|attr| has_non_empty_value(&attr)); let has_title = element .find_attribute_by_name("title") .is_some_and(|attr| has_non_empty_value(&attr)); - has_aria_label || has_title + has_aria_label || has_aria_labelledby || has_title } /// Type-specific variant for `HtmlSelfClosingElement`. Checks non-empty attribute. From fee99c5e838becb53d307164ac75038753897f5f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:09:20 +0000 Subject: [PATCH 06/11] [autofix.ci] apply automated fixes --- crates/biome_html_analyze/src/a11y.rs | 8 +++++--- crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 47905b866e57..3db06403e867 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -18,8 +18,7 @@ fn get_attribute_string_value(attribute: &HtmlAttribute) -> Option { /// Checks if an attribute has a truthy `aria-hidden` value. /// Returns `true` if value is not "false" (case-insensitive) or if no value is provided. fn is_truthy_aria_hidden_value(attribute: &HtmlAttribute) -> bool { - get_attribute_string_value(attribute) - .is_none_or(|value| !value.eq_ignore_ascii_case("false")) + get_attribute_string_value(attribute).is_none_or(|value| !value.eq_ignore_ascii_case("false")) } /// Checks if an attribute value equals "true" exactly (case-sensitive). @@ -33,7 +32,10 @@ fn has_non_empty_value(attribute: &HtmlAttribute) -> bool { } /// Checks if an attribute value matches a specific string (case-insensitive). -pub(crate) fn attribute_value_equals_ignore_case(attribute: &HtmlAttribute, expected: &str) -> bool { +pub(crate) fn attribute_value_equals_ignore_case( + attribute: &HtmlAttribute, + expected: &str, +) -> bool { get_attribute_string_value(attribute).is_some_and(|value| value.eq_ignore_ascii_case(expected)) } diff --git a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs index dfef7dec1b72..f6d40e1d59cd 100644 --- a/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs +++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs @@ -7,7 +7,9 @@ use biome_html_syntax::{AnyHtmlElement, HtmlFileSource}; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_alt_text::UseAltTextOptions; -use crate::a11y::{attribute_value_equals_ignore_case, has_non_empty_attribute, is_aria_hidden_true}; +use crate::a11y::{ + attribute_value_equals_ignore_case, has_non_empty_attribute, is_aria_hidden_true, +}; declare_lint_rule! { /// Enforce that all elements that require alternative text have meaningful information to relay back to the end user. From 2434aecc0c1d4cc14670debd295490a69dea13a9 Mon Sep 17 00:00:00 2001 From: rahuld109 Date: Thu, 22 Jan 2026 16:16:05 -0800 Subject: [PATCH 07/11] docs(biome_html_analyze): add docstrings to a11y helpers --- crates/biome_html_analyze/src/a11y.rs | 100 ++++++++++++++++++++------ 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/crates/biome_html_analyze/src/a11y.rs b/crates/biome_html_analyze/src/a11y.rs index 3db06403e867..ed744edc563c 100644 --- a/crates/biome_html_analyze/src/a11y.rs +++ b/crates/biome_html_analyze/src/a11y.rs @@ -1,13 +1,24 @@ +//! Shared accessibility helper functions for HTML lint rules. +//! +//! This module provides reusable utilities for checking accessibility-related +//! attributes and element states. All helpers follow a layered architecture: +//! +//! 1. **Core helpers** (private): Low-level attribute value extraction and checks +//! 2. **Element helpers** (public): Higher-level checks on HTML elements +//! 3. **Type-specific variants** (public): Optimized versions that avoid cloning + use biome_html_syntax::element_ext::AnyHtmlTagElement; use biome_html_syntax::{AnyHtmlElement, HtmlAttribute}; use biome_rowan::Text; // ============================================================================ -// Core attribute value helpers (shared logic extracted here) +// Core attribute value helpers (private) // ============================================================================ /// Extracts the string value from an attribute's initializer. +/// /// This is the fundamental building block for all attribute value checks. +/// Returns `None` if the attribute has no initializer or the value cannot be extracted. fn get_attribute_string_value(attribute: &HtmlAttribute) -> Option { attribute .initializer() @@ -15,23 +26,37 @@ fn get_attribute_string_value(attribute: &HtmlAttribute) -> Option { .and_then(|value| value.string_value()) } -/// Checks if an attribute has a truthy `aria-hidden` value. -/// Returns `true` if value is not "false" (case-insensitive) or if no value is provided. +/// Checks if an `aria-hidden` attribute has a truthy value. +/// +/// Per ARIA spec, `aria-hidden` is truthy when: +/// - The attribute is present with no value (`aria-hidden`) +/// - The value is anything other than "false" (case-insensitive) +/// +/// This means `aria-hidden=""`, `aria-hidden="true"`, and `aria-hidden="yes"` +/// are all considered truthy. fn is_truthy_aria_hidden_value(attribute: &HtmlAttribute) -> bool { get_attribute_string_value(attribute).is_none_or(|value| !value.eq_ignore_ascii_case("false")) } -/// Checks if an attribute value equals "true" exactly (case-sensitive). +/// Checks if an attribute value equals `"true"` exactly (case-sensitive). +/// +/// Used for strict boolean attribute checks where only the literal string +/// `"true"` should match, not truthy values like `"yes"` or `"1"`. fn is_strict_true_value(attribute: &HtmlAttribute) -> bool { get_attribute_string_value(attribute).is_some_and(|value| value == "true") } -/// Checks if an attribute has a non-empty trimmed value. +/// Checks if an attribute has a non-empty value after trimming whitespace. +/// +/// Returns `false` for attributes with no value, empty strings, or whitespace-only values. fn has_non_empty_value(attribute: &HtmlAttribute) -> bool { get_attribute_string_value(attribute).is_some_and(|value| !value.trim().is_empty()) } -/// Checks if an attribute value matches a specific string (case-insensitive). +/// Checks if an attribute value matches the expected string (case-insensitive). +/// +/// Useful for checking HTML attribute values like `type="hidden"` or `role="button"` +/// where the comparison should be case-insensitive per HTML spec. pub(crate) fn attribute_value_equals_ignore_case( attribute: &HtmlAttribute, expected: &str, @@ -40,15 +65,19 @@ pub(crate) fn attribute_value_equals_ignore_case( } // ============================================================================ -// Element-level helpers (use core helpers above) +// Element-level helpers (public) // ============================================================================ -/// Check the element is hidden from screen reader. +/// Returns `true` if the element is hidden from assistive technologies. +/// +/// An element is hidden from screen readers when: +/// - It has a truthy `aria-hidden` attribute +/// - It is an `` element /// /// Ref: -/// - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden -/// - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden -/// - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js +/// - +/// -