diff --git a/.changeset/fix-use-alt-text.md b/.changeset/fix-use-alt-text.md new file mode 100644 index 000000000000..23bca5c474ad --- /dev/null +++ b/.changeset/fix-use-alt-text.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": patch +--- + +Fixed [#9349](https://github.com/biomejs/biome/issues/9349): Biome now correctly handles Vue dynamic `:alt` and `v-bind:alt` bindings in `useAltText`, preventing false positives in `.vue` files. 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 49e6f254c97f..b514acb86e1b 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 @@ -200,8 +200,5 @@ fn has_type_image_attribute(element: &AnyHtmlElement) -> bool { /// 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() + element.find_attribute_or_vue_binding("alt").is_some() } diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue new file mode 100644 index 000000000000..7c1cc69992a2 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap new file mode 100644 index 000000000000..0b495869cea6 --- /dev/null +++ b/crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap @@ -0,0 +1,18 @@ +--- +source: crates/biome_html_analyze/tests/spec_tests.rs +expression: valid.vue +--- +# Input +```html + + + + + + + + + + + +``` diff --git a/crates/biome_html_syntax/src/element_ext.rs b/crates/biome_html_syntax/src/element_ext.rs index d8465c65fc0c..348dae1eed05 100644 --- a/crates/biome_html_syntax/src/element_ext.rs +++ b/crates/biome_html_syntax/src/element_ext.rs @@ -1,9 +1,10 @@ use crate::{ - AnyHtmlContent, AnyHtmlElement, AnyHtmlTagName, AnyHtmlTextExpression, AnySvelteBlock, - AstroEmbeddedContent, HtmlAttribute, HtmlAttributeList, HtmlElement, HtmlEmbeddedContent, - HtmlOpeningElement, HtmlSelfClosingElement, HtmlSyntaxToken, HtmlTagName, ScriptType, - inner_string_text, + AnyHtmlAttribute, AnyHtmlContent, AnyHtmlElement, AnyHtmlTagName, AnyHtmlTextExpression, + AnySvelteBlock, AnyVueDirective, AstroEmbeddedContent, HtmlAttribute, HtmlAttributeList, + HtmlElement, HtmlEmbeddedContent, HtmlOpeningElement, HtmlSelfClosingElement, HtmlSyntaxToken, + HtmlTagName, ScriptType, inner_string_text, }; + use biome_rowan::{AstNodeList, SyntaxResult, TokenText, declare_node_union}; /// https://html.spec.whatwg.org/#void-elements @@ -123,6 +124,58 @@ impl AnyHtmlElement { _ => None, } } + + /// Check if the element has a given HTML attribute or a Vue v-bind binding + /// targeting the same attribute name. + /// + /// Handles: + /// - `name="..."` — standard HTML attribute + /// - `:name="..."` — Vue v-bind shorthand (`VueVBindShorthandDirective`) + /// - `v-bind:name="..."` — explicit Vue v-bind (`VueDirective`) + pub fn find_attribute_or_vue_binding(&self, name_to_lookup: &str) -> Option { + let attrs = self.attributes()?; + + attrs.iter().find_map(|attr| { + let matches = match &attr { + AnyHtmlAttribute::HtmlAttribute(a) => a + .name() + .ok() + .and_then(|n| n.value_token().ok()) + .is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)), + + AnyHtmlAttribute::AnyVueDirective(vue) => match vue { + // :name="..." + AnyVueDirective::VueVBindShorthandDirective(d) => d + .arg() + .ok() + .and_then(|arg| arg.arg().ok()) + .and_then(|arg| arg.as_vue_static_argument().cloned()) + .and_then(|s| s.name_token().ok()) + .is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)), + + // v-bind:name="..." + AnyVueDirective::VueDirective(d) => { + let is_bind = d + .name_token() + .is_ok_and(|t| t.text_trimmed().eq_ignore_ascii_case("v-bind")); + is_bind + && d.arg() + .and_then(|arg| arg.arg().ok()) + .and_then(|arg| arg.as_vue_static_argument().cloned()) + .and_then(|s| s.name_token().ok()) + .is_some_and(|t| { + t.text_trimmed().eq_ignore_ascii_case(name_to_lookup) + }) + } + + _ => false, + }, + + _ => false, + }; + if matches { Some(attr) } else { None } + }) + } } impl HtmlSelfClosingElement {