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 + /// photo content; + /// ``` + /// + /// ```html,expect_diagnostic + /// picture of cool person; + /// ``` + /// + /// ### Valid + /// + /// ```html + /// <> + /// alt + /// Picture of me taking a photo of an image + /// + /// ``` + /// + 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 @@ + +Photo of friend. +Picture of friend. +Image of friend. +PhOtO of friend. +{"photo"} +piCTUre of friend. +imAGE of friend. +image of cool person +imAGE of friend. 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 + +Photo of friend. +Picture of friend. +Image of friend. +PhOtO of friend. +{"photo"} +piCTUre of friend. +imAGE of friend. +image of cool person +imAGE of friend. + +``` + +_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 │ Photo of friend. + │ ^^^^^^^^^^^^^^^^^^ + 3 │ Picture of friend. + 4 │ Image of friend. + + 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 │ Photo of friend. + > 3 │ Picture of friend. + │ ^^^^^^^^^^^^^^^^^^^^ + 4 │ Image of friend. + 5 │ PhOtO of friend. + + 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 │ Photo of friend. + 3 │ Picture of friend. + > 4 │ Image of friend. + │ ^^^^^^^^^^^^^^^^^^ + 5 │ PhOtO of friend. + 6 │ {"photo"} + + 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 │ Picture of friend. + 4 │ Image of friend. + > 5 │ PhOtO of friend. + │ ^^^^^^^^^^^^^^^^^^ + 6 │ {"photo"} + 7 │ piCTUre of friend. + + 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 │ PhOtO of friend. + 6 │ {"photo"} + > 7 │ piCTUre of friend. + │ ^^^^^^^^^^^^^^^^^^^^ + 8 │ imAGE of friend. + 9 │ image of cool person + + 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 │ {"photo"} + 7 │ piCTUre of friend. + > 8 │ imAGE of friend. + │ ^^^^^^^^^^^^^^^^^^ + 9 │ image of cool person + 10 │ imAGE of friend. + + 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 │ piCTUre of friend. + 8 │ imAGE of friend. + > 9 │ image of cool person + │ ^^^^^^^^^^^^^^^^^^^^^^ + 10 │ imAGE of friend. + 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 │ imAGE of friend. + 9 │ image of cool person + > 10 │ imAGE of friend. + │ ^^^^^^^^^^^^^^^^^^ + 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 @@ + +Photo of friend. +Picture of friend. +Image of friend. +PhOtO of friend. +{"photo"} +piCTUre of friend. +imAGE of friend. +image of cool person 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 + +Photo of friend. +Picture of friend. +Image of friend. +PhOtO of friend. +{"photo"} +piCTUre of friend. +imAGE of friend. +image of cool person + +``` + +_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 │ Photo of friend. + │ ^^^^^^^^^^^^^^^^^^ + 3 │ Picture of friend. + 4 │ Image of friend. + + 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 │ Photo of friend. + > 3 │ Picture of friend. + │ ^^^^^^^^^^^^^^^^^^^^ + 4 │ Image of friend. + 5 │ PhOtO of friend. + + 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 │ Photo of friend. + 3 │ Picture of friend. + > 4 │ Image of friend. + │ ^^^^^^^^^^^^^^^^^^ + 5 │ PhOtO of friend. + 6 │ {"photo"} + + 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 │ Picture of friend. + 4 │ Image of friend. + > 5 │ PhOtO of friend. + │ ^^^^^^^^^^^^^^^^^^ + 6 │ {"photo"} + 7 │ piCTUre of friend. + + 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 │ PhOtO of friend. + 6 │ {"photo"} + > 7 │ piCTUre of friend. + │ ^^^^^^^^^^^^^^^^^^^^ + 8 │ imAGE of friend. + 9 │ image of cool person + + 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 │ {"photo"} + 7 │ piCTUre of friend. + > 8 │ imAGE of friend. + │ ^^^^^^^^^^^^^^^^^^ + 9 │ image of cool person + 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 │ piCTUre of friend. + 8 │ imAGE of friend. + > 9 │ image of cool person + │ ^^^^^^^^^^^^^^^^^^^^^^ + 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 @@ + +foo +picture of me taking a photo of an image +photo of image + +foo + + + +Doing cool things. +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 + +foo +picture of me taking a photo of an image +photo of image + +foo + + + +Doing cool things. +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 @@ + +foo +picture of me taking a photo of an image +photo of image + +foo + + + +Doing cool things. +test + +image of cool person + 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 + +foo +picture of me taking a photo of an image +photo of image + +foo + + + +Doing cool things. +test + +image of cool person + + +``` 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) + }) +}