From 22ce373b23517f4f0ecaf59f46f16f609a0c0a9b Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sat, 3 Jan 2026 17:07:29 +0800 Subject: [PATCH 1/7] feat(html): add useAltText a11y rule Add a new HTML accessibility lint rule that enforces alternative text on elements that require it: - img: requires alt, aria-label, or aria-labelledby - area: requires alt, aria-label, or aria-labelledby - input[type=image]: requires alt, aria-label, or aria-labelledby - object: requires title, aria-label, or aria-labelledby Elements with aria-hidden="true" are exempt from this rule. Empty alt="" is valid for decorative images. Part of the HTML a11y umbrella issue #8155. --- .changeset/html-use-alt-text.md | 5 + crates/biome_html_analyze/src/lint/a11y.rs | 3 +- .../src/lint/a11y/use_alt_text.rs | 247 ++++++++++++++++++ .../tests/specs/a11y/useAltText/invalid.html | 15 ++ .../specs/a11y/useAltText/invalid.html.snap | 171 ++++++++++++ .../tests/specs/a11y/useAltText/valid.html | 55 ++++ .../specs/a11y/useAltText/valid.html.snap | 63 +++++ 7 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 .changeset/html-use-alt-text.md create mode 100644 crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs create mode 100644 crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html create mode 100644 crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap create mode 100644 crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html create mode 100644 crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap diff --git a/.changeset/html-use-alt-text.md b/.changeset/html-use-alt-text.md new file mode 100644 index 000000000000..768eb5d84453 --- /dev/null +++ b/.changeset/html-use-alt-text.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": minor +--- + +Added the [`useAltText`](https://biomejs.dev/linter/rules/use-alt-text/) lint rule for HTML. This rule enforces that elements requiring alternative text (``, ``, ``, ``) provide meaningful information for screen reader users via `alt`, `title` (for objects), `aria-label`, or `aria-labelledby` attributes. Elements with `aria-hidden="true"` are exempt. diff --git a/crates/biome_html_analyze/src/lint/a11y.rs b/crates/biome_html_analyze/src/lint/a11y.rs index b276d6550d59..bf58bab83f45 100644 --- a/crates/biome_html_analyze/src/lint/a11y.rs +++ b/crates/biome_html_analyze/src/lint/a11y.rs @@ -9,9 +9,10 @@ pub mod no_distracting_elements; pub mod no_header_scope; pub mod no_positive_tabindex; pub mod no_svg_without_title; +pub mod use_alt_text; pub mod use_aria_props_for_role; pub mod use_button_type; pub mod use_html_lang; pub mod use_iframe_title; pub mod use_valid_aria_role; -declare_lint_group! { pub A11y { name : "a11y" , rules : [self :: no_access_key :: NoAccessKey , self :: no_autofocus :: NoAutofocus , self :: no_distracting_elements :: NoDistractingElements , self :: no_header_scope :: NoHeaderScope , self :: no_positive_tabindex :: NoPositiveTabindex , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: use_aria_props_for_role :: UseAriaPropsForRole , self :: use_button_type :: UseButtonType , self :: use_html_lang :: UseHtmlLang , self :: use_iframe_title :: UseIframeTitle , self :: use_valid_aria_role :: UseValidAriaRole ,] } } +declare_lint_group! { pub A11y { name : "a11y" , rules : [self :: no_access_key :: NoAccessKey , self :: no_autofocus :: NoAutofocus , self :: no_distracting_elements :: NoDistractingElements , self :: no_header_scope :: NoHeaderScope , self :: no_positive_tabindex :: NoPositiveTabindex , self :: no_svg_without_title :: NoSvgWithoutTitle , self :: use_alt_text :: UseAltText , self :: use_aria_props_for_role :: UseAriaPropsForRole , self :: use_button_type :: UseButtonType , self :: use_html_lang :: UseHtmlLang , self :: use_iframe_title :: UseIframeTitle , self :: use_valid_aria_role :: UseValidAriaRole ,] } } 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 new file mode 100644 index 000000000000..75ca475edac6 --- /dev/null +++ b/crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs @@ -0,0 +1,247 @@ +use biome_analyze::{ + Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule, +}; +use biome_console::{fmt::Display, fmt::Formatter, markup}; +use biome_diagnostics::Severity; +use biome_html_syntax::AnyHtmlElement; +use biome_rowan::{AstNode, TextRange}; +use biome_rule_options::use_alt_text::UseAltTextOptions; + +declare_lint_rule! { + /// Enforce that all elements that require alternative text have meaningful information to relay back to the end user. + /// + /// This is a critical component of accessibility for screen reader users in order for them + /// to understand the content's purpose on the page. + /// By default, this rule checks for alternative text on the following elements: + /// ``, ``, ``, and ``. + /// + /// :::note + /// In `.html` files, this rule matches element names case-insensitively (e.g., ``, ``). + /// + /// In component-based frameworks (Vue, Svelte, Astro), only lowercase element names are checked. + /// PascalCase variants like `` are assumed to be custom components and are ignored. + /// ::: + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ```html,expect_diagnostic + /// + /// ``` + /// + /// ### Valid + /// + /// ```html + /// A beautiful landscape + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ```html + /// + /// + /// ``` + /// + /// ```html + /// + /// ``` + /// + /// ## Accessibility guidelines + /// + /// - [WCAG 1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html) + /// + pub UseAltText { + version: "next", + name: "useAltText", + language: "html", + sources: &[RuleSource::EslintJsxA11y("alt-text").same()], + recommended: true, + severity: Severity::Error, + } +} + +/// The type of element being validated +pub enum ValidatedElement { + Object, + Img, + Area, + Input, +} + +impl Display for ValidatedElement { + fn fmt(&self, fmt: &mut Formatter) -> std::io::Result<()> { + match self { + Self::Object => fmt.write_markup(markup!("title")), + _ => fmt.write_markup(markup!("alt")), + } + } +} + +impl Rule for UseAltText { + type Query = Ast; + type State = (ValidatedElement, TextRange); + type Signals = Option; + type Options = UseAltTextOptions; + + fn run(ctx: &RuleContext) -> Self::Signals { + let element = ctx.query(); + let file_extension = ctx.file_path().extension()?; + + let element_name = element.name()?; + let is_html_file = file_extension == "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 name_matches = |name: &str| -> bool { + if is_html_file { + element_name.eq_ignore_ascii_case(name) + } else { + element_name.text() == name + } + }; + + if name_matches("object") { + let has_title = has_valid_label(element, "title"); + + if !has_title && !has_aria_label && !has_aria_labelledby && !aria_hidden { + // For object elements, check if it has accessible child content + // In HTML, we can't easily check for accessible children, so we flag all + // object elements without title/aria-label/aria-labelledby + return Some(( + ValidatedElement::Object, + element.syntax().text_trimmed_range(), + )); + } + } else if name_matches("img") { + if !has_alt && !has_aria_label && !has_aria_labelledby && !aria_hidden { + return Some((ValidatedElement::Img, element.syntax().text_trimmed_range())); + } + } else if name_matches("area") { + if !has_alt && !has_aria_label && !has_aria_labelledby && !aria_hidden { + return Some(( + ValidatedElement::Area, + element.syntax().text_trimmed_range(), + )); + } + } else if name_matches("input") + && has_type_image_attribute(element) + && !has_alt + && !has_aria_label + && !has_aria_labelledby + && !aria_hidden + { + return Some(( + ValidatedElement::Input, + element.syntax().text_trimmed_range(), + )); + } + + None + } + + fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option { + let (validated_element, range) = state; + let message = markup!( + "Provide a text alternative through the "{validated_element}", ""aria-label"", or ""aria-labelledby"" attribute." + ) + .to_owned(); + Some( + RuleDiagnostic::new(rule_category!(), range, message) + .note(markup! { + "Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page." + }) + .note(markup! { + "If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the ""aria-hidden"" attribute." + }), + ) + } +} + +/// Check if the element has a type="image" attribute +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")) + }) +} + +/// Check if the element has a valid alt attribute +fn has_valid_alt_text(element: &AnyHtmlElement) -> bool { + // The alt attribute exists - even an empty alt="" is valid for decorative images + // If there's no initializer, it's treated as an empty string (valid) + // If there's an initializer with a value, any value is valid + 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| { + // aria-hidden without a value or with value "true" means hidden + let Some(initializer) = attribute.initializer() else { + // aria-hidden present without value - treat as true + return true; + }; + + initializer + .value() + .ok() + .and_then(|value| value.string_value()) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + }) +} diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html new file mode 100644 index 000000000000..2f1c4bc1a055 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap new file mode 100644 index 000000000000..6458b7c83bcb --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap @@ -0,0 +1,171 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: invalid.html +--- +# Input +```html + + + + + + + + + + + + + + + + +``` + +# Diagnostics +``` +invalid.html:2:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 1 │ + > 2 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^ + 3 │ + 4 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:3:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 1 │ + 2 │ + > 3 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 4 │ + 5 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:6:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 5 │ + > 6 │ + │ ^^^^^^^^^^^^^^^^^^^ + 7 │ + 8 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:7:17 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 5 │ + 6 │ + > 7 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 8 │ + 9 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:10:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 9 │ + > 10 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 11 │ + 12 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:11:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. + + 9 │ + 10 │ + > 11 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │ + 13 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:14:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the title, aria-label, or aria-labelledby attribute. + + 13 │ + > 14 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 15 │ + 16 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` + +``` +invalid.html:15:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + × Provide a text alternative through the title, aria-label, or aria-labelledby attribute. + + 13 │ + 14 │ + > 15 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 16 │ + + i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. + + i If the content is decorative, redundant, or obscured, consider hiding it from assistive technologies with the aria-hidden attribute. + + +``` diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html new file mode 100644 index 000000000000..fee094f03776 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html @@ -0,0 +1,55 @@ + +A beautiful landscape +Profile picture + + + + + + + + + + + + + + + + + Link area + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Image +Link + + diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap new file mode 100644 index 000000000000..820e4896f29e --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap @@ -0,0 +1,63 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: valid.html +--- +# Input +```html + +A beautiful landscape +Profile picture + + + + + + + + + + + + + + + + + Link area + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Image +Link + + + +``` From 099ef130e85e169d79a82f25e034d2a63830c6f3 Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sat, 3 Jan 2026 17:26:29 +0800 Subject: [PATCH 2/7] fix: align aria-hidden exemption with noSvgWithoutTitle - Only exempt aria-hidden="true" (with explicit value), not bare aria-hidden - Add magic comments to test files per project convention --- .../src/lint/a11y/use_alt_text.rs | 15 +-- .../tests/specs/a11y/useAltText/invalid.html | 2 + .../specs/a11y/useAltText/invalid.html.snap | 92 ++++++++++--------- .../tests/specs/a11y/useAltText/valid.html | 5 +- .../specs/a11y/useAltText/valid.html.snap | 5 +- 5 files changed, 60 insertions(+), 59 deletions(-) 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 75ca475edac6..333908821214 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 @@ -232,16 +232,11 @@ fn is_aria_hidden(element: &AnyHtmlElement) -> bool { element .find_attribute_by_name("aria-hidden") .is_some_and(|attribute| { - // aria-hidden without a value or with value "true" means hidden - let Some(initializer) = attribute.initializer() else { - // aria-hidden present without value - treat as true - return true; - }; - - initializer - .value() - .ok() + // 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.eq_ignore_ascii_case("true")) + .is_some_and(|value| value == "true") }) } diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html index 2f1c4bc1a055..7a916dd39940 100644 --- a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html @@ -1,3 +1,5 @@ + + diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap index 6458b7c83bcb..3845d82ed889 100644 --- a/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/invalid.html.snap @@ -4,6 +4,8 @@ expression: invalid.html --- # Input ```html + + @@ -24,15 +26,15 @@ expression: invalid.html # Diagnostics ``` -invalid.html:2:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:4:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 1 │ - > 2 │ + 3 │ + > 4 │ │ ^^^^^^^^^^^^^^^^^^^^^^^ - 3 │ - 4 │ + 5 │ + 6 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -42,16 +44,16 @@ invalid.html:2:1 lint/a11y/useAltText ━━━━━━━━━━━━━━ ``` ``` -invalid.html:3:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:5:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 1 │ - 2 │ - > 3 │ + 3 │ + 4 │ + > 5 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 4 │ - 5 │ + 6 │ + 7 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -61,15 +63,15 @@ invalid.html:3:1 lint/a11y/useAltText ━━━━━━━━━━━━━━ ``` ``` -invalid.html:6:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:8:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 5 │ - > 6 │ - │ ^^^^^^^^^^^^^^^^^^^ - 7 │ - 8 │ + 7 │ + > 8 │ + │ ^^^^^^^^^^^^^^^^^^^ + 9 │ + 10 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -79,16 +81,16 @@ invalid.html:6:1 lint/a11y/useAltText ━━━━━━━━━━━━━━ ``` ``` -invalid.html:7:17 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:9:17 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 5 │ - 6 │ - > 7 │ - │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 8 │ - 9 │ + 7 │ + 8 │ + > 9 │ + │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 10 │ + 11 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -98,15 +100,15 @@ invalid.html:7:17 lint/a11y/useAltText ━━━━━━━━━━━━━ ``` ``` -invalid.html:10:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:12:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 9 │ - > 10 │ + 11 │ + > 12 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 11 │ - 12 │ + 13 │ + 14 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -116,16 +118,16 @@ invalid.html:10:1 lint/a11y/useAltText ━━━━━━━━━━━━━ ``` ``` -invalid.html:11:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:13:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the alt, aria-label, or aria-labelledby attribute. - 9 │ - 10 │ - > 11 │ + 11 │ + 12 │ + > 13 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 12 │ - 13 │ + 14 │ + 15 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -135,15 +137,15 @@ invalid.html:11:1 lint/a11y/useAltText ━━━━━━━━━━━━━ ``` ``` -invalid.html:14:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:16:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the title, aria-label, or aria-labelledby attribute. - 13 │ - > 14 │ + 15 │ + > 16 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 15 │ - 16 │ + 17 │ + 18 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. @@ -153,15 +155,15 @@ invalid.html:14:1 lint/a11y/useAltText ━━━━━━━━━━━━━ ``` ``` -invalid.html:15:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +invalid.html:17:1 lint/a11y/useAltText ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Provide a text alternative through the title, aria-label, or aria-labelledby attribute. - 13 │ - 14 │ - > 15 │ + 15 │ + 16 │ + > 17 │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - 16 │ + 18 │ i Meaningful alternative text on elements helps users relying on screen readers to understand content's purpose within a page. diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html index fee094f03776..03610bd1d94d 100644 --- a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html @@ -1,3 +1,5 @@ + + A beautiful landscape Profile picture @@ -11,9 +13,8 @@ - + - diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap index 820e4896f29e..5865495078ec 100644 --- a/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/valid.html.snap @@ -4,6 +4,8 @@ expression: valid.html --- # Input ```html + + A beautiful landscape Profile picture @@ -17,9 +19,8 @@ expression: valid.html - + - From 9ce5277d2b4fc4e7cbc0ce4899d799df8883db6e Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sun, 4 Jan 2026 00:34:04 +0800 Subject: [PATCH 3/7] refactor(html): use HtmlFileSource instead of file extension check Replace `ctx.file_path().extension()` with `ctx.source_type::()` as suggested by reviewer. This change: - Adds `source_type` parameter to `analyze()` and `analyze_with_inspect_matcher()` - Registers `HtmlFileSource` in analyzer services - Updates all call sites in biome_service and tests The file extension approach was problematic because valid HTML files might not have the `.html` extension. Using `HtmlFileSource` is more future-proof and relies on Biome's type system rather than filename conventions. --- crates/biome_html_analyze/src/lib.rs | 12 +++++++++--- .../src/lint/a11y/use_alt_text.rs | 6 +++--- crates/biome_html_analyze/tests/spec_tests.rs | 2 +- crates/biome_service/src/file_handlers/html.rs | 13 +++++++++---- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/biome_html_analyze/src/lib.rs b/crates/biome_html_analyze/src/lib.rs index 30850f0f47cd..6c2fbb2fe6ae 100644 --- a/crates/biome_html_analyze/src/lib.rs +++ b/crates/biome_html_analyze/src/lib.rs @@ -15,7 +15,7 @@ use biome_analyze::{ }; use biome_deserialize::TextRange; use biome_diagnostics::Error; -use biome_html_syntax::HtmlLanguage; +use biome_html_syntax::{HtmlFileSource, HtmlLanguage}; use biome_suppression::{SuppressionDiagnostic, parse_suppression_comment}; use std::ops::Deref; use std::sync::LazyLock; @@ -35,13 +35,14 @@ pub fn analyze<'a, F, B>( root: &LanguageRoot, filter: AnalysisFilter, options: &'a AnalyzerOptions, + source_type: HtmlFileSource, emit_signal: F, ) -> (Option, Vec) where F: FnMut(&dyn AnalyzerSignal) -> ControlFlow + 'a, B: 'a, { - analyze_with_inspect_matcher(root, filter, |_| {}, options, emit_signal) + analyze_with_inspect_matcher(root, filter, |_| {}, options, source_type, emit_signal) } /// Run the analyzer on the provided `root`: this process will use the given `filter` @@ -55,6 +56,7 @@ pub fn analyze_with_inspect_matcher<'a, V, F, B>( filter: AnalysisFilter, inspect_matcher: V, options: &'a AnalyzerOptions, + source_type: HtmlFileSource, mut emit_signal: F, ) -> (Option, Vec) where @@ -91,13 +93,15 @@ where let mut registry = RuleRegistry::builder(&filter, root); visit_registry(&mut registry); - let (registry, services, diagnostics, visitors) = registry.build(); + let (registry, mut services, diagnostics, visitors) = registry.build(); // Bail if we can't parse a rule option if !diagnostics.is_empty() { return (None, diagnostics); } + services.insert_service(source_type); + let mut analyzer = biome_analyze::Analyzer::new( METADATA.deref(), biome_analyze::InspectMatcher::new(registry, inspect_matcher), @@ -130,6 +134,7 @@ mod tests { use biome_diagnostics::termcolor::NoColor; use biome_diagnostics::{Diagnostic, DiagnosticExt, PrintDiagnostic, Severity}; use biome_html_parser::parse_html; + use biome_html_syntax::HtmlFileSource; use biome_rowan::TextRange; use std::slice; @@ -159,6 +164,7 @@ mod tests { ..AnalysisFilter::default() }, &options, + HtmlFileSource::html(), |signal| { if let Some(diag) = signal.diagnostic() { error_ranges.push(diag.location().span.unwrap()); 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 333908821214..e0544bb6ebf3 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 @@ -3,7 +3,7 @@ use biome_analyze::{ }; use biome_console::{fmt::Display, fmt::Formatter, markup}; use biome_diagnostics::Severity; -use biome_html_syntax::AnyHtmlElement; +use biome_html_syntax::{AnyHtmlElement, HtmlFileSource}; use biome_rowan::{AstNode, TextRange}; use biome_rule_options::use_alt_text::UseAltTextOptions; @@ -112,10 +112,10 @@ impl Rule for UseAltText { fn run(ctx: &RuleContext) -> Self::Signals { let element = ctx.query(); - let file_extension = ctx.file_path().extension()?; + let file_source = ctx.source_type::(); let element_name = element.name()?; - let is_html_file = file_extension == "html"; + 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"); diff --git a/crates/biome_html_analyze/tests/spec_tests.rs b/crates/biome_html_analyze/tests/spec_tests.rs index 48b6a87d305e..8c3ce3baace6 100644 --- a/crates/biome_html_analyze/tests/spec_tests.rs +++ b/crates/biome_html_analyze/tests/spec_tests.rs @@ -100,7 +100,7 @@ pub(crate) fn analyze_and_snap( let mut code_fixes = Vec::new(); let options = create_analyzer_options::(input_file, &mut diagnostics); - let (_, errors) = biome_html_analyze::analyze(&root, filter, &options, |event| { + let (_, errors) = biome_html_analyze::analyze(&root, filter, &options, source_type, |event| { if let Some(mut diag) = event.diagnostic() { for action in event.actions() { if check_action_type.is_suppression() { diff --git a/crates/biome_service/src/file_handlers/html.rs b/crates/biome_service/src/file_handlers/html.rs index 4c888d54d2c5..d975dadaef19 100644 --- a/crates/biome_service/src/file_handlers/html.rs +++ b/crates/biome_service/src/file_handlers/html.rs @@ -793,7 +793,8 @@ fn lint(params: LintParams) -> LintResults { let mut process_lint = ProcessLint::new(¶ms); - let (_, analyze_diagnostics) = analyze(&tree, filter, &analyzer_options, |signal| { + let source_type = params.language.to_html_file_source().unwrap_or_default(); + let (_, analyze_diagnostics) = analyze(&tree, filter, &analyzer_options, source_type, |signal| { process_lint.process_signal(signal) }); @@ -826,7 +827,7 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { let _ = debug_span!("Code actions HTML", range =? range, path =? path).entered(); let tree = parse.tree(); let _ = trace_span!("Parsed file", tree =? tree).entered(); - let Some(_) = language.to_html_file_source() else { + let Some(source_type) = language.to_html_file_source() else { error!("Could not determine the HTML file source of the file"); return PullActionsResult { actions: Vec::new(), @@ -851,7 +852,7 @@ pub(crate) fn code_actions(params: CodeActionsParams) -> PullActionsResult { range, }; - analyze(&tree, filter, &analyzer_options, |signal| { + analyze(&tree, filter, &analyzer_options, source_type, |signal| { actions.extend(signal.actions().into_code_action_iter().map(|item| { CodeAction { category: item.category.clone(), @@ -905,8 +906,12 @@ pub(crate) fn fix_all(params: FixAllParams) -> Result Date: Sat, 3 Jan 2026 16:39:46 +0000 Subject: [PATCH 4/7] [autofix.ci] apply automated fixes --- crates/biome_service/src/file_handlers/html.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/biome_service/src/file_handlers/html.rs b/crates/biome_service/src/file_handlers/html.rs index d975dadaef19..4daad8e970b7 100644 --- a/crates/biome_service/src/file_handlers/html.rs +++ b/crates/biome_service/src/file_handlers/html.rs @@ -794,9 +794,10 @@ fn lint(params: LintParams) -> LintResults { let mut process_lint = ProcessLint::new(¶ms); let source_type = params.language.to_html_file_source().unwrap_or_default(); - let (_, analyze_diagnostics) = analyze(&tree, filter, &analyzer_options, source_type, |signal| { - process_lint.process_signal(signal) - }); + let (_, analyze_diagnostics) = + analyze(&tree, filter, &analyzer_options, source_type, |signal| { + process_lint.process_signal(signal) + }); process_lint.into_result( params From 3c4d894e844f6f3566bcc797baa6b0a431b26aaa Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sun, 4 Jan 2026 00:44:06 +0800 Subject: [PATCH 5/7] fix: update rules_check to use new analyze() API with source_type --- xtask/rules_check/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 177b272f5b55..7ce66775d04f 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -20,7 +20,7 @@ use biome_deserialize::json::deserialize_from_json_ast; use biome_diagnostics::{DiagnosticExt, PrintDiagnostic, Severity}; use biome_graphql_syntax::GraphqlLanguage; use biome_html_parser::HtmlParseOptions; -use biome_html_syntax::HtmlLanguage; +use biome_html_syntax::{HtmlFileSource, HtmlLanguage}; use biome_js_parser::JsParserOptions; use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, TextSize}; use biome_json_analyze::JsonAnalyzeServices; @@ -508,7 +508,7 @@ fn assert_lint( let options = test.create_analyzer_options::(config)?; - biome_html_analyze::analyze(&root, filter, &options, |signal| { + biome_html_analyze::analyze(&root, filter, &options, HtmlFileSource::html(), |signal| { if let Some(mut diag) = signal.diagnostic() { for action in signal.actions() { if !action.is_suppression() { From 33c5a24a4bfc0cad9c22a0c1309d50d14389c3a6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:49:36 +0000 Subject: [PATCH 6/7] [autofix.ci] apply automated fixes --- xtask/rules_check/src/lib.rs | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 7ce66775d04f..1c1b27c82b63 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -508,22 +508,28 @@ fn assert_lint( let options = test.create_analyzer_options::(config)?; - biome_html_analyze::analyze(&root, filter, &options, HtmlFileSource::html(), |signal| { - if let Some(mut diag) = signal.diagnostic() { - for action in signal.actions() { - if !action.is_suppression() { - diag = diag.add_code_suggestion(action.into()); + biome_html_analyze::analyze( + &root, + filter, + &options, + HtmlFileSource::html(), + |signal| { + if let Some(mut diag) = signal.diagnostic() { + for action in signal.actions() { + if !action.is_suppression() { + diag = diag.add_code_suggestion(action.into()); + } } - } - let error = diag - .with_file_path(test.file_path()) - .with_file_source_code(code); - diagnostics.write_diagnostic(error); - } + let error = diag + .with_file_path(test.file_path()) + .with_file_source_code(code); + diagnostics.write_diagnostic(error); + } - ControlFlow::<()>::Continue(()) - }); + ControlFlow::<()>::Continue(()) + }, + ); } } DocumentFileSource::Grit(..) => todo!("Grit analysis is not yet supported"), From ef3aaa70591b939b033385e9b6f7bad10df470fd Mon Sep 17 00:00:00 2001 From: Tu Shaokun <2801884530@qq.com> Date: Sun, 4 Jan 2026 00:51:26 +0800 Subject: [PATCH 7/7] refactor: use extracted source instead of HtmlFileSource::html() --- xtask/rules_check/src/lib.rs | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 1c1b27c82b63..d476cb2b3aa3 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -20,7 +20,7 @@ use biome_deserialize::json::deserialize_from_json_ast; use biome_diagnostics::{DiagnosticExt, PrintDiagnostic, Severity}; use biome_graphql_syntax::GraphqlLanguage; use biome_html_parser::HtmlParseOptions; -use biome_html_syntax::{HtmlFileSource, HtmlLanguage}; +use biome_html_syntax::HtmlLanguage; use biome_js_parser::JsParserOptions; use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage, TextSize}; use biome_json_analyze::JsonAnalyzeServices; @@ -508,28 +508,22 @@ fn assert_lint( let options = test.create_analyzer_options::(config)?; - biome_html_analyze::analyze( - &root, - filter, - &options, - HtmlFileSource::html(), - |signal| { - if let Some(mut diag) = signal.diagnostic() { - for action in signal.actions() { - if !action.is_suppression() { - diag = diag.add_code_suggestion(action.into()); - } + biome_html_analyze::analyze(&root, filter, &options, source, |signal| { + if let Some(mut diag) = signal.diagnostic() { + for action in signal.actions() { + if !action.is_suppression() { + diag = diag.add_code_suggestion(action.into()); } - - let error = diag - .with_file_path(test.file_path()) - .with_file_source_code(code); - diagnostics.write_diagnostic(error); } - ControlFlow::<()>::Continue(()) - }, - ); + let error = diag + .with_file_path(test.file_path()) + .with_file_source_code(code); + diagnostics.write_diagnostic(error); + } + + ControlFlow::<()>::Continue(()) + }); } } DocumentFileSource::Grit(..) => todo!("Grit analysis is not yet supported"),