diff --git a/.typos.toml b/.typos.toml index 33ed14be6698d..3bc8705445dd9 100644 --- a/.typos.toml +++ b/.typos.toml @@ -10,6 +10,7 @@ extend-exclude = [ "tasks/prettier_conformance/prettier", "crates/oxc_parser/src/lexer/mod.rs", "crates/oxc_linter/fixtures", + "crates/oxc_linter/src/rules/jsx_a11y/img_redundant_alt.rs", "crates/oxc_syntax/src/xml_entities.rs", "**/*.snap", "pnpm-lock.yaml", diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 5b590195e1269..ae631bb339108 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -213,6 +213,7 @@ mod jsx_a11y { pub mod anchor_is_valid; pub mod heading_has_content; pub mod html_has_lang; + pub mod img_redundant_alt; } oxc_macros::declare_all_lint_rules! { @@ -400,5 +401,6 @@ oxc_macros::declare_all_lint_rules! { jsx_a11y::anchor_has_content, jsx_a11y::anchor_is_valid, jsx_a11y::html_has_lang, - jsx_a11y::heading_has_content + jsx_a11y::heading_has_content, + jsx_a11y::img_redundant_alt } diff --git a/crates/oxc_linter/src/rules/jsx_a11y/img_redundant_alt.rs b/crates/oxc_linter/src/rules/jsx_a11y/img_redundant_alt.rs new file mode 100644 index 0000000000000..939b0af169c23 --- /dev/null +++ b/crates/oxc_linter/src/rules/jsx_a11y/img_redundant_alt.rs @@ -0,0 +1,256 @@ +use regex::Regex; + +use oxc_ast::{ + ast::{ + Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElementName, + JSXExpression, JSXExpressionContainer, + }, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::utils::{get_prop_value, has_jsx_prop_lowercase, is_hidden_from_screen_reader}; +use crate::{context::LintContext, rule::Rule, AstNode}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute.")] +#[diagnostic(severity(warning), help("Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom words) in the alt prop."))] +struct ImgRedundantAltDiagnostic(#[label] pub Span); + +declare_oxc_lint!( + /// ### What it does + /// + /// Enforce img alt attribute does not contain the word image, picture, or photo. Screenreaders already announce img elements as an image. + /// There is no need to use words such as image, photo, and/or picture. + /// + /// ### Why is this necessary? + /// + /// Alternative text is a critical component of accessibility for screen + /// reader users, enabling them to understand the content and function + /// of an element. + /// + /// ### What it checks + /// + /// This rule checks for alternative text on the following elements: + /// `` and the components which you define in options.components with the exception of components which is hidden from screen reader. + /// + /// ### Example + /// ```javascript + /// // Bad + /// Photo of foo being weird. + /// Image of me at a bar! + /// Picture of baz fixing a bug. + /// + /// // Good + /// Foo eating a sandwich. + /// Picture of me taking a photo of an image // Will pass because it is hidden. + /// {`Baz // This is valid since photo is a variable name. + /// ``` + ImgRedundantAlt, + correctness +); + +#[derive(Debug, Clone)] +pub struct ImgRedundantAlt { + types_to_validate: Vec, + redundant_words: Vec, +} + +impl std::default::Default for ImgRedundantAlt { + fn default() -> Self { + Self { + types_to_validate: COMPONENTS_FIXED_TO_VALIDATE + .iter() + .map(|&s| s.to_string()) + .collect(), + redundant_words: REDUNDANT_WORDS.iter().map(|&s| s.to_string()).collect(), + } + } +} + +const COMPONENTS_FIXED_TO_VALIDATE: [&str; 1] = ["img"]; +const REDUNDANT_WORDS: [&str; 3] = ["image", "photo", "picture"]; + +impl Rule for ImgRedundantAlt { + fn from_configuration(value: serde_json::Value) -> Self { + let mut img_redundant_alt = Self::default(); + if let Some(config) = value.get(0) { + if let Some(components) = config.get("components").and_then(|v| v.as_array()) { + img_redundant_alt + .types_to_validate + .extend(components.iter().filter_map(|v| v.as_str().map(ToString::to_string))); + } + + if let Some(words) = config.get("words").and_then(|v| v.as_array()) { + img_redundant_alt + .redundant_words + .extend(words.iter().filter_map(|v| v.as_str().map(ToString::to_string))); + } + } + + img_redundant_alt + } + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::JSXOpeningElement(jsx_el) = node.kind() else { return }; + let JSXElementName::Identifier(iden) = &jsx_el.name else { return }; + let name = iden.name.as_str(); + + if !self.types_to_validate.iter().any(|comp| comp == name) { + return; + } + + if is_hidden_from_screen_reader(jsx_el) { + return; + } + + let alt_prop = match has_jsx_prop_lowercase(jsx_el, "alt") { + Some(v) => v, + None => { + return; + } + }; + + let alt_attribute = match get_prop_value(alt_prop) { + Some(v) => v, + None => { + return; + } + }; + + let alt_attribute_name = match alt_prop { + JSXAttributeItem::Attribute(attr) => &attr.name, + JSXAttributeItem::SpreadAttribute(_) => { + return; + } + }; + + let alt_attribute_name_span = match alt_attribute_name { + JSXAttributeName::Identifier(iden) => iden.span, + JSXAttributeName::NamespacedName(namespaced_name) => namespaced_name.span, + }; + + match alt_attribute { + JSXAttributeValue::StringLiteral(lit) => { + let alt_text = lit.value.as_str(); + + if is_redundant_alt_text(alt_text, &self.redundant_words) { + ctx.diagnostic(ImgRedundantAltDiagnostic(alt_attribute_name_span)); + } + } + JSXAttributeValue::ExpressionContainer(JSXExpressionContainer { + expression: JSXExpression::Expression(expression), + .. + }) => match expression { + Expression::StringLiteral(lit) => { + let alt_text = lit.value.as_str(); + + if is_redundant_alt_text(alt_text, &self.redundant_words) { + ctx.diagnostic(ImgRedundantAltDiagnostic(alt_attribute_name_span)); + } + } + Expression::TemplateLiteral(lit) => { + for quasi in &lit.quasis { + let alt_text = quasi.value.raw.as_str(); + + if is_redundant_alt_text(alt_text, &self.redundant_words) { + ctx.diagnostic(ImgRedundantAltDiagnostic(alt_attribute_name_span)); + } + } + } + _ => {} + }, + _ => {} + }; + } +} + +fn is_redundant_alt_text(alt_text: &str, redundant_words: &[String]) -> bool { + let regexp = Regex::new(&format!(r"(?i)\b({})\b", redundant_words.join("|"),)).unwrap(); + + regexp.is_match(alt_text) +} + +#[test] +fn test() { + use crate::tester::Tester; + + fn array() -> serde_json::Value { + serde_json::json!([{ + "components": ["Image"], + "words": ["Word1", "Word2"] + }]) + } + + let pass = vec![ + (r"foo;", None), + (r"picture of me taking a photo of an image", None), + (r"photo of image", None), + (r"foo;", None), + (r"foo", None), + (r"{'foo'}", None), + (r"{alt}", None), + (r"", None), + (r"", None), + (r"", None), + (r"{undefined}", None), + (r"{`this", None), + (r"{`this", None), + (r"{`this", None), + (r"{`this", None), + (r"{`${photo}`}", None), + (r"{`${image}`}", None), + (r"{`${picture}`}", None), + (r"{'undefined'}", None), + (r"{() {}} />", None), + (r"{function(e){}}", None), + (r"Doing cool things.", None), + (r"test", None), + (r"", None), + (r"{imageAlt}", None), + (r"{imageAlt.name}", None), + (r"{imageAlt?.name}", None), + (r"Doing cool things", None), + (r"Photography;", None), + (r"ImageMagick;", None), + (r"Photo of a friend", None), + // TODO we need components_settings to test this + // (r"Foo", settings: Some(components_settings)) + ]; + + let fail = vec![ + (r"Photo of friend.;", None), + (r"Picture of friend.;", None), + (r"Image of friend.;", None), + (r"PhOtO of friend.;", None), + (r"{'photo'};", None), + (r"piCTUre of friend.;", None), + (r"imAGE of friend.;", None), + (r"photo of cool person", None), + (r"picture of cool person", None), + (r"image of cool person", None), + (r"photo", None), + (r"image", None), + (r"picture", None), + (r"{`picture", None), + (r"{`photo", None), + (r"{`image", None), + (r"{`picture", None), + (r"{`photo", None), + (r"{`image", None), + // TODO we need components_settings to test this + // (r"Photo of a friend", Some(components_settings), + + // TESTS FOR ARRAY OPTION TESTS + (r"Word1;", Some(array())), + (r"Word2;", Some(array())), + (r"Word1;", Some(array())), + (r"Word2;", Some(array())), + ]; + + Tester::new(ImgRedundantAlt::NAME, pass, fail).with_jsx_a11y_plugin(true).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/img_redundant_alt.snap b/crates/oxc_linter/src/snapshots/img_redundant_alt.snap new file mode 100644 index 0000000000000..42030de5ce856 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/img_redundant_alt.snap @@ -0,0 +1,189 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: img_redundant_alt +--- + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Photo of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Picture of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Image of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ PhOtO of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {'photo'}; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ piCTUre of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ imAGE of friend.; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ photo of cool person + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ picture of cool person + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ image of cool person + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ photo + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ image + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ picture + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`picture + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`photo + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`image + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`picture + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`photo + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ {`image + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Word1; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Word2; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Word1; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + + ⚠ eslint-plugin-jsx-a11y(img-redundant-alt): Redundant alt attribute. + ╭─[img_redundant_alt.tsx:1:1] + 1 │ Word2; + · ─── + ╰──── + help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don’t need to use the words `image`, `photo,` or `picture` (or any specified custom + words) in the alt prop. + +