diff --git a/.changeset/small-seas-laugh.md b/.changeset/small-seas-laugh.md
new file mode 100644
index 000000000000..2846ef08a715
--- /dev/null
+++ b/.changeset/small-seas-laugh.md
@@ -0,0 +1,5 @@
+---
+"@biomejs/biome": minor
+---
+
+Added the rule [`noRedundantAlt`](https://biomejs.dev/linter/rules/no-redundant-alt/) to HTML. The rule enforces that the `img` element `alt` attribute does not contain the words “image”, “picture”, or “photo”.
diff --git a/crates/biome_html_analyze/src/lint/a11y.rs b/crates/biome_html_analyze/src/lint/a11y.rs
index bf58bab83f45..73946eb0f7e4 100644
--- a/crates/biome_html_analyze/src/lint/a11y.rs
+++ b/crates/biome_html_analyze/src/lint/a11y.rs
@@ -8,6 +8,7 @@ pub mod no_autofocus;
pub mod no_distracting_elements;
pub mod no_header_scope;
pub mod no_positive_tabindex;
+pub mod no_redundant_alt;
pub mod no_svg_without_title;
pub mod use_alt_text;
pub mod use_aria_props_for_role;
@@ -15,4 +16,4 @@ 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_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 ,] } }
+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_redundant_alt :: NoRedundantAlt , 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/no_redundant_alt.rs b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs
new file mode 100644
index 000000000000..3815042f473b
--- /dev/null
+++ b/crates/biome_html_analyze/src/lint/a11y/no_redundant_alt.rs
@@ -0,0 +1,121 @@
+use biome_analyze::{
+ Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
+};
+use biome_console::markup;
+use biome_diagnostics::Severity;
+use biome_html_syntax::element_ext::AnyHtmlTagElement;
+use biome_html_syntax::{AnyHtmlAttributeInitializer, HtmlFileSource};
+use biome_rowan::AstNode;
+use biome_rule_options::is_redundant_alt;
+use biome_rule_options::no_redundant_alt::NoRedundantAltOptions;
+
+declare_lint_rule! {
+ /// Enforce `img` alt prop does not contain the word "image", "picture", or "photo".
+ ///
+ /// The rule will first check if `aria-hidden` is truthy to determine whether to enforce the rule. If the image is
+ /// hidden, then the rule will always succeed.
+ ///
+ /// ## Examples
+ ///
+ /// ### Invalid
+ ///
+ /// ```html,expect_diagnostic
+ ///
;
+ /// ```
+ ///
+ /// ```html,expect_diagnostic
+ ///
;
+ /// ```
+ ///
+ /// ### Valid
+ ///
+ /// ```html
+ /// <>
+ ///
+ ///
+ /// >
+ /// ```
+ ///
+ pub NoRedundantAlt {
+ version: "next",
+ name: "noRedundantAlt",
+ language: "html",
+ sources: &[RuleSource::EslintJsxA11y("img-redundant-alt").same()],
+ recommended: true,
+ severity: Severity::Error,
+ }
+}
+
+impl Rule for NoRedundantAlt {
+ type Query = Ast;
+ type State = AnyHtmlAttributeInitializer;
+ type Signals = Option;
+ type Options = NoRedundantAltOptions;
+
+ fn run(ctx: &RuleContext) -> Self::Signals {
+ let node = ctx.query();
+ let file_source = ctx.source_type::();
+
+ let name = node.name().ok()?.value_token().ok()?;
+ if (file_source.is_html() && !name.text_trimmed().eq_ignore_ascii_case("img"))
+ || (!file_source.is_html() && name.text_trimmed() != "img")
+ {
+ 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;
+ }
+ }
+
+ let alt = node
+ .find_attribute_by_name("alt")?
+ .initializer()?
+ .value()
+ .ok()?;
+
+ match alt {
+ AnyHtmlAttributeInitializer::HtmlSingleTextExpression(ref expression) => {
+ let value = expression.expression().ok()?.html_literal_token().ok()?;
+
+ is_redundant_alt(value.text_trimmed()).then_some(alt)
+ }
+ AnyHtmlAttributeInitializer::HtmlString(ref value) => {
+ let inner_string_text = value.inner_string_text().ok()?;
+ is_redundant_alt(inner_string_text.text()).then_some(alt)
+ }
+ }
+ }
+
+ fn diagnostic(_ctx: &RuleContext, state: &Self::State) -> Option {
+ Some(
+ RuleDiagnostic::new(
+ rule_category!(),
+ state.range(),
+ markup! {
+ "Avoid the words \"image\", \"picture\", or \"photo\" in " "img"" element alt text."
+ },
+ )
+ .note(markup! {
+ "Screen readers announce img elements as \"images\", so it is not necessary to redeclare this in alternative text."
+ }),
+ )
+ }
+}
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html
new file mode 100644
index 000000000000..f176c0356c62
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html.snap
new file mode 100644
index 000000000000..be84855affb5
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.html.snap
@@ -0,0 +1,155 @@
+---
+source: crates/biome_html_analyze/tests/spec_tests.rs
+expression: invalid.html
+---
+# Input
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+_Note: The parser emitted 2 diagnostics which are not shown here._
+
+# Diagnostics
+```
+invalid.html:2:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 1 │
+ > 2 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 3 │
+ 4 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:3:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 1 │
+ 2 │
+ > 3 │
+ │ ^^^^^^^^^^^^^^^^^^^^
+ 4 │
+ 5 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:4:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 2 │
+ 3 │
+ > 4 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 5 │
+ 6 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:5:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 3 │
+ 4 │
+ > 5 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 6 │
+ 7 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:7:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 5 │
+ 6 │
+ > 7 │
+ │ ^^^^^^^^^^^^^^^^^^^^
+ 8 │
+ 9 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:8:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 6 │
+ 7 │
+ > 8 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 9 │
+ 10 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:9:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 7 │
+ 8 │
+ > 9 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^
+ 10 │
+ 11 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.html:10:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 8 │
+ 9 │
+ > 10 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 11 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue
new file mode 100644
index 000000000000..d6c6d26f3a3c
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue.snap b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue.snap
new file mode 100644
index 000000000000..928d0ff71a0d
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/invalid.vue.snap
@@ -0,0 +1,137 @@
+---
+source: crates/biome_html_analyze/tests/spec_tests.rs
+expression: invalid.vue
+---
+# Input
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+_Note: The parser emitted 2 diagnostics which are not shown here._
+
+# Diagnostics
+```
+invalid.vue:2:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 1 │
+ > 2 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 3 │
+ 4 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:3:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 1 │
+ 2 │
+ > 3 │
+ │ ^^^^^^^^^^^^^^^^^^^^
+ 4 │
+ 5 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:4:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 2 │
+ 3 │
+ > 4 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 5 │
+ 6 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:5:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 3 │
+ 4 │
+ > 5 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 6 │
+ 7 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:7:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 5 │
+ 6 │
+ > 7 │
+ │ ^^^^^^^^^^^^^^^^^^^^
+ 8 │
+ 9 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:8:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 6 │
+ 7 │
+ > 8 │
+ │ ^^^^^^^^^^^^^^^^^^
+ 9 │
+ 10 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
+
+```
+invalid.vue:9:10 lint/a11y/noRedundantAlt ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+ × Avoid the words "image", "picture", or "photo" in img element alt text.
+
+ 7 │
+ 8 │
+ > 9 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^
+ 10 │
+
+ i Screen readers announce img elements as "images", so it is not necessary to redeclare this in alternative text.
+
+
+```
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html
new file mode 100644
index 000000000000..c071e2b2d72e
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+test
+
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html.snap b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html.snap
new file mode 100644
index 000000000000..19fba44a764d
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.html.snap
@@ -0,0 +1,20 @@
+---
+source: crates/biome_html_analyze/tests/spec_tests.rs
+expression: valid.html
+---
+# Input
+```html
+
+
+
+
+
+
+
+
+
+
+test
+
+
+```
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue
new file mode 100644
index 000000000000..df4fb70a3110
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+test
+
+
+
diff --git a/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue.snap b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue.snap
new file mode 100644
index 000000000000..bfc23be26a0b
--- /dev/null
+++ b/crates/biome_html_analyze/tests/specs/a11y/noRedundantAlt/valid.vue.snap
@@ -0,0 +1,23 @@
+---
+source: crates/biome_html_analyze/tests/spec_tests.rs
+expression: valid.vue
+---
+# Input
+```html
+
+
+
+
+
+
+
+
+
+
+test
+
+
+
+
+```
diff --git a/crates/biome_html_syntax/src/element_ext.rs b/crates/biome_html_syntax/src/element_ext.rs
index 7edc02fd2320..444d0c9bacd3 100644
--- a/crates/biome_html_syntax/src/element_ext.rs
+++ b/crates/biome_html_syntax/src/element_ext.rs
@@ -219,6 +219,10 @@ impl HtmlElement {
pub fn is_sass_lang(&self) -> bool {
self.is_style_tag() && self.has_attribute("lang", "scss")
}
+
+ pub fn name(&self) -> SyntaxResult {
+ self.opening_element()?.name()
+ }
}
impl HtmlTagName {
diff --git a/crates/biome_js_analyze/src/lint/a11y/no_redundant_alt.rs b/crates/biome_js_analyze/src/lint/a11y/no_redundant_alt.rs
index 2b6dfcfb4975..b8f3d9d22215 100644
--- a/crates/biome_js_analyze/src/lint/a11y/no_redundant_alt.rs
+++ b/crates/biome_js_analyze/src/lint/a11y/no_redundant_alt.rs
@@ -7,8 +7,8 @@ use biome_js_syntax::{
AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, AnyJsxAttributeValue,
};
use biome_rowan::AstNode;
+use biome_rule_options::is_redundant_alt;
use biome_rule_options::no_redundant_alt::NoRedundantAltOptions;
-use biome_string_case::StrLikeExtension;
declare_lint_rule! {
/// Enforce `img` alt prop does not contain the word "image", "picture", or "photo".
@@ -142,12 +142,3 @@ impl Rule for NoRedundantAlt {
)
}
}
-
-const REDUNDANT_WORDS: [&str; 3] = ["image", "photo", "picture"];
-
-fn is_redundant_alt(alt: &str) -> bool {
- REDUNDANT_WORDS.into_iter().any(|word| {
- alt.split_whitespace()
- .any(|x| x.to_ascii_lowercase_cow() == word)
- })
-}
diff --git a/crates/biome_rule_options/src/no_redundant_alt.rs b/crates/biome_rule_options/src/no_redundant_alt.rs
index ddd508af5c60..1f0c7de5dd3e 100644
--- a/crates/biome_rule_options/src/no_redundant_alt.rs
+++ b/crates/biome_rule_options/src/no_redundant_alt.rs
@@ -1,5 +1,6 @@
use biome_deserialize_macros::{Deserializable, Merge};
use serde::{Deserialize, Serialize};
+
#[derive(Default, Clone, Debug, Deserialize, Deserializable, Merge, Eq, PartialEq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields, default)]
diff --git a/crates/biome_rule_options/src/shared/mod.rs b/crates/biome_rule_options/src/shared/mod.rs
index a9965fbb4866..9817d15c6053 100644
--- a/crates/biome_rule_options/src/shared/mod.rs
+++ b/crates/biome_rule_options/src/shared/mod.rs
@@ -1,2 +1,13 @@
+use biome_string_case::StrLikeExtension;
+
pub mod restricted_regex;
pub mod sort_order;
+
+const REDUNDANT_WORDS: [&str; 3] = ["image", "photo", "picture"];
+
+pub fn is_redundant_alt(alt: &str) -> bool {
+ REDUNDANT_WORDS.into_iter().any(|word| {
+ alt.split_whitespace()
+ .any(|x| x.to_ascii_lowercase_cow() == word)
+ })
+}