Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 101 additions & 47 deletions __tests__/src/rules/img-redundant-alt-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,25 @@ const componentsSettings = {

const ruleTester = new RuleTester();

const expectedError = {
message: 'Redundant alt attribute. 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.',
// Fired when the entire alt is nothing but redundant words — no useful content at all.
const redundantAltError = {
messageId: 'redundantAlt',
type: 'JSXOpeningElement',
};

// Fired when the alt opens with a redundant word followed by "of" — the prefix adds
// nothing over the screen-reader's own announcement; the description after "of" is enough.
const redundantAltPrefixError = {
messageId: 'redundantAltPrefix',
type: 'JSXOpeningElement',
};

ruleTester.run('img-redundant-alt', rule, {
valid: parsers.all([].concat(
// -------------------------------------------------------------------
// Baseline — unrelated or variable alts
// -------------------------------------------------------------------
{ code: '<img alt="foo" />;' },
{ code: '<img alt="picture of me taking a photo of an image" aria-hidden />' },
{ code: '<img aria-hidden alt="photo of image" />' },
{ code: '<img ALt="foo" />;' },
{ code: '<img {...this.props} alt="foo" />' },
{ code: '<img {...this.props} alt={"foo"} />' },
Expand Down Expand Up @@ -70,68 +79,113 @@ ruleTester.run('img-redundant-alt', rule, {
{ code: '<img alt={imageAlt?.name} />', languageOptions: { ecmaVersion: 2020 } },
{ code: '<img alt="Doing cool things" aria-hidden={foo?.bar}/>', languageOptions: { ecmaVersion: 2020 } },
] : [],

// -------------------------------------------------------------------
// Words that contain a redundant substring but are not the word itself
// -------------------------------------------------------------------
{ code: '<img alt="Photography" />;' },
{ code: '<img alt="ImageMagick" />;' },

// -------------------------------------------------------------------
// aria-hidden — rule never fires regardless of alt content
// -------------------------------------------------------------------
{ code: '<img alt="picture of me taking a photo of an image" aria-hidden />' },
{ code: '<img aria-hidden alt="photo of image" />' },

// -------------------------------------------------------------------
// Compound nouns and descriptive labels: redundant word is part of a
// meaningful phrase, NOT a bare announcement of image type.
// "disk image", "profile photo", "cover picture" all convey real info.
// -------------------------------------------------------------------
{ code: '<img alt="disk image" />' },
{ code: '<img alt="profile photo" />' },
{ code: '<img alt="cover picture" />' },
{ code: '<img alt="passport photo ID" />' },
{ code: '<img alt="before and after photo collage" />' },
{ code: '<img alt="satellite image overlay" />' },
{ code: '<img alt="Picture Perfect album cover" />' },
{ code: '<img alt="Photo finish at 100m race" />' },
{ code: '<img alt="Photo booth selfie" />' },
{ code: '<img alt="body image awareness poster" />' },

// -------------------------------------------------------------------
// Template literals with non-redundant context words
// -------------------------------------------------------------------
{ code: '<img alt={`picture doing ${things}`} {...this.props} />' },
{ code: '<img alt={`Book cover photo ${index} of ${total}`} {...this.props} />' },
{ code: '<img alt={`${dockerImageName} image running`} {...this.props} />' },

// -------------------------------------------------------------------
// Custom component not mapped to img — Image is not validated by default
// -------------------------------------------------------------------
{ code: '<Image alt="Photo of a friend" />' },
{ code: '<Image alt="Foo" />', settings: componentsSettings },

// -------------------------------------------------------------------
// Non-ASCII: only exact-word matches are flagged
// -------------------------------------------------------------------
{ code: '<img alt="画像" />', options: [{ words: ['イメージ'] }] },
{ code: '<img alt="イメージです" />', options: [{ words: ['イメージ'] }] },
)).map(parserOptionsMapper),

invalid: parsers.all([].concat(
{ code: '<img alt="Photo of friend." />;', errors: [expectedError] },
{ code: '<img alt="Picture of friend." />;', errors: [expectedError] },
{ code: '<img alt="Image of friend." />;', errors: [expectedError] },
{ code: '<img alt="PhOtO of friend." />;', errors: [expectedError] },
{ code: '<img alt={"photo"} />;', errors: [expectedError] },
{ code: '<img alt="piCTUre of friend." />;', errors: [expectedError] },
{ code: '<img alt="imAGE of friend." />;', errors: [expectedError] },
// -------------------------------------------------------------------
// Pure redundant alts — the entire alt is one or more redundant words,
// conveying nothing a screen-reader doesn't already announce.
// -------------------------------------------------------------------
{ code: '<img alt={"photo"} />;', errors: [redundantAltError] },
{ code: '<img alt="photo" {...this.props} />', errors: [redundantAltError] },
{ code: '<img alt="image" {...this.props} />', errors: [redundantAltError] },
{ code: '<img alt="picture" {...this.props} />', errors: [redundantAltError] },

// -------------------------------------------------------------------
// "word of ..." prefix — redundant word + "of" adds no meaning over
// the screen-reader announcement; only the description after "of" matters.
// -------------------------------------------------------------------
{ code: '<img alt="Photo of friend." />;', errors: [redundantAltPrefixError] },
{ code: '<img alt="Picture of friend." />;', errors: [redundantAltPrefixError] },
{ code: '<img alt="Image of friend." />;', errors: [redundantAltPrefixError] },
{ code: '<img alt="PhOtO of friend." />;', errors: [redundantAltPrefixError] },
{ code: '<img alt="piCTUre of friend." />;', errors: [redundantAltPrefixError] },
{ code: '<img alt="imAGE of friend." />;', errors: [redundantAltPrefixError] },
{
code: '<img alt="photo of cool person" aria-hidden={false} />',
errors: [expectedError],
errors: [redundantAltPrefixError],
},
{
code: '<img alt="picture of cool person" aria-hidden={false} />',
errors: [expectedError],
errors: [redundantAltPrefixError],
},
{
code: '<img alt="image of cool person" aria-hidden={false} />',
errors: [expectedError],
},
{ code: '<img alt="photo" {...this.props} />', errors: [expectedError] },
{ code: '<img alt="image" {...this.props} />', errors: [expectedError] },
{ code: '<img alt="picture" {...this.props} />', errors: [expectedError] },
{
code: '<img alt={`picture doing ${things}`} {...this.props} />',
errors: [expectedError],
},
{
code: '<img alt={`photo doing ${things}`} {...this.props} />',
errors: [expectedError],
},
{
code: '<img alt={`image doing ${things}`} {...this.props} />',
errors: [expectedError],
},
{
code: '<img alt={`picture doing ${picture}`} {...this.props} />',
errors: [expectedError],
},
{
code: '<img alt={`photo doing ${photo}`} {...this.props} />',
errors: [expectedError],
errors: [redundantAltPrefixError],
},
// Demonstrates the value of the prefix check: "image of a diagram" should
// just be "diagram" — the "image of" is pure noise for a screen-reader user.
{ code: '<img alt="image of a diagram" />', errors: [redundantAltPrefixError] },
{ code: '<img alt="photo of the building entrance" />', errors: [redundantAltPrefixError] },
{ code: '<img alt="picture of a flow chart" />', errors: [redundantAltPrefixError] },

// -------------------------------------------------------------------
// Custom component mapped to img via settings
// -------------------------------------------------------------------
{
code: '<img alt={`image doing ${image}`} {...this.props} />',
errors: [expectedError],
code: '<Image alt="Photo of a friend" />',
errors: [redundantAltPrefixError],
settings: componentsSettings,
},
{ code: '<Image alt="Photo of a friend" />', errors: [expectedError], settings: componentsSettings },

// TESTS FOR ARRAY OPTION TESTS
{ code: '<img alt="Word1" />;', options: array, errors: [expectedError] },
{ code: '<img alt="Word2" />;', options: array, errors: [expectedError] },
{ code: '<Image alt="Word1" />;', options: array, errors: [expectedError] },
{ code: '<Image alt="Word2" />;', options: array, errors: [expectedError] },
// -------------------------------------------------------------------
// Custom words option
// -------------------------------------------------------------------
{ code: '<img alt="Word1" />;', options: array, errors: [redundantAltError] },
{ code: '<img alt="Word2" />;', options: array, errors: [redundantAltError] },
{ code: '<Image alt="Word1" />;', options: array, errors: [redundantAltError] },
{ code: '<Image alt="Word2" />;', options: array, errors: [redundantAltError] },

{ code: '<img alt="イメージ" />', options: [{ words: ['イメージ'] }], errors: [expectedError] },
{ code: '<img alt="イメージです" />', options: [{ words: ['イメージ'] }], errors: [expectedError] },
// -------------------------------------------------------------------
// Non-ASCII exact-word match
// -------------------------------------------------------------------
{ code: '<img alt="イメージ" />', options: [{ words: ['イメージ'] }], errors: [redundantAltError] },
)).map(parserOptionsMapper),
});
55 changes: 38 additions & 17 deletions src/rules/img-redundant-alt.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

import { getProp, getLiteralPropValue } from 'jsx-ast-utils';
import includes from 'array-includes';
import stringIncludes from 'string.prototype.includes';
import safeRegexTest from 'safe-regex-test';
import { generateObjSchema, arraySchema } from '../util/schemas';
import getElementType from '../util/getElementType';
Expand All @@ -21,22 +20,35 @@ const REDUNDANT_WORDS = [
'picture',
];

const errorMessage = 'Redundant alt attribute. 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.';

const schema = generateObjSchema({
components: arraySchema,
words: arraySchema,
});

const isASCII = safeRegexTest(/[\x20-\x7F]+/);

function containsRedundantWord(value, redundantWords) {
const lowercaseRedundantWords = redundantWords.map((redundantWord) => redundantWord.toLowerCase());
function getWords(value) {
return value.split(/\s+/).filter((w) => w.length > 0);
}

// Flags alts whose every word is a redundant word, e.g. "image", "photo picture".
// Compound nouns like "disk image" or "profile photo" are intentionally allowed.
function isOnlyRedundantWords(value, redundantWords) {
const lowerRedundant = redundantWords.map((w) => w.toLowerCase());
const words = getWords(value);
return words.length > 0 && words.every((w) => includes(lowerRedundant, w.toLowerCase()));
}

if (isASCII(value)) {
return value.split(/\s+/).some((valueWord) => includes(lowercaseRedundantWords, valueWord.toLowerCase()));
}
return lowercaseRedundantWords.some((redundantWord) => stringIncludes(value.toLowerCase(), redundantWord));
// Flags alts that open with a redundant word followed by "of", e.g. "image of a cat".
// The "of X" phrasing contributes no additional meaning over the screen-reader announcement.
function startsWithRedundantWordOf(value, redundantWords) {
const lowerRedundant = redundantWords.map((w) => w.toLowerCase());
const words = getWords(value);
return (
words.length >= 2
&& includes(lowerRedundant, words[0].toLowerCase())
&& words[1].toLowerCase() === 'of'
);
}

export default {
Expand All @@ -45,6 +57,10 @@ export default {
url: 'https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/img-redundant-alt.md',
description: 'Enforce `<img>` alt prop does not contain the word "image", "picture", or "photo".',
},
messages: {
redundantAlt: 'Redundant alt attribute. 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.',
redundantAltPrefix: 'Redundant alt prefix. Screen-readers already announce `img` tags as an image. Remove the leading "image of", "photo of", or "picture of" prefix — the descriptive part that follows is sufficient.',
},
schema: [schema],
},

Expand All @@ -57,7 +73,7 @@ export default {
const typesToValidate = ['img'].concat(componentOptions);
const nodeType = elementType(node);

// Only check 'label' elements and custom types.
// Only check 'img' elements and custom types.
if (typesToValidate.indexOf(nodeType) === -1) {
return;
}
Expand All @@ -77,13 +93,18 @@ export default {
const redundantWords = REDUNDANT_WORDS.concat(words);

if (typeof value === 'string' && isVisible) {
const hasRedundancy = containsRedundantWord(value, redundantWords);

if (hasRedundancy === true) {
context.report({
node,
message: errorMessage,
});
if (isASCII(value)) {
if (isOnlyRedundantWords(value, redundantWords)) {
context.report({ node, messageId: 'redundantAlt' });
} else if (startsWithRedundantWordOf(value, redundantWords)) {
context.report({ node, messageId: 'redundantAltPrefix' });
}
} else {
// For non-ASCII text, flag only when the entire value is itself a redundant word.
const lowerValue = value.trim().toLowerCase();
if (redundantWords.map((w) => w.toLowerCase()).some((rw) => lowerValue === rw)) {
context.report({ node, messageId: 'redundantAlt' });
}
}
}
},
Expand Down
Loading