Skip to content
Merged
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
5 changes: 5 additions & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ This page contains documentation for all Herb Linter rules.
- [`a11y-no-accesskey-attribute`](./a11y-no-accesskey-attribute.md) - Prevent usage of the `accesskey` attribute
- [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements
- [`a11y-no-autofocus-attribute`](./a11y-no-autofocus-attribute.md) - Prevent usage of the `autofocus` attribute
- [`a11y-no-redundant-image-alt`](./a11y-no-redundant-image-alt.md) - Prevent redundant words in `<img>` `alt` attributes


#### Action View

- [`actionview-no-silent-helper`](./actionview-no-silent-helper.md) - Disallow silent ERB tags for Action View helpers
- [`actionview-no-silent-render`](./actionview-no-silent-render.md) - Disallow calling `render` without outputting the result
- [`actionview-no-unnecessary-tag-attributes`](./actionview-no-unnecessary-tag-attributes.md) - Disallow unnecessary attributes on Action View tag helpers
- [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements
- [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after.
- [`actionview-strict-locals-partial-only`](./actionview-strict-locals-partial-only.md) - Only allow strict local definitions in partial files.
Expand All @@ -26,6 +28,7 @@ This page contains documentation for all Herb Linter rules.
- [`erb-no-case-node-children`](./erb-no-case-node-children.md) - Don't use `children` for `case/when` and `case/in` nodes
- [`erb-no-debug-output`](./erb-no-debug-output.md) - Disallow debug output methods (`p`, `pp`, `puts`, `print`, `debug`) in ERB templates
- [`erb-no-conditional-html-element`](./erb-no-conditional-html-element.md) - Disallow conditional HTML elements
- [`erb-no-conditional-open-tag`](./erb-no-conditional-open-tag.md) - Disallow conditional HTML open tags
- [`erb-no-duplicate-branch-elements`](./erb-no-duplicate-branch-elements.md) - Disallow duplicate elements across conditional branches
- [`erb-no-empty-control-flow`](./erb-no-empty-control-flow.md) - Disallow empty ERB control flow blocks
- [`erb-no-empty-tags`](./erb-no-empty-tags.md) - Disallow empty ERB tags
Expand Down Expand Up @@ -97,6 +100,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-duplicate-ids`](./html-no-duplicate-ids.md) - Prevents duplicate IDs within a document
- [`html-no-duplicate-meta-names`](./html-no-duplicate-meta-names.md) - Duplicate `<meta>` name attributes are not allowed.
- [`html-no-empty-attributes`](./html-no-empty-attributes.md) - Attributes must not have empty values
- [`html-no-empty-headings`](./html-no-empty-headings.md) - Disallow empty heading elements
- [`html-no-nested-links`](./html-no-nested-links.md) - Prevents nested anchor tags
- [`html-no-positive-tab-index`](./html-no-positive-tab-index.md) - Avoid positive `tabindex` values
- [`html-no-self-closing`](./html-no-self-closing.md) - Disallow self closing tags
Expand All @@ -105,6 +109,7 @@ This page contains documentation for all Herb Linter rules.
- [`html-no-space-in-tag`](./html-no-space-in-tag.md) - Disallow spaces in HTML tags
- [`html-no-title-attribute`](./html-no-title-attribute.md) - Avoid using the `title` attribute
- [`html-no-underscores-in-attribute-names`](./html-no-underscores-in-attribute-names.md) - Disallow underscores in HTML attribute names
- [`html-require-closing-tags`](./html-require-closing-tags.md) - Require closing tags for non-void HTML elements
- [`html-require-script-nonce`](./html-require-script-nonce.md) - Require `nonce` attribute on script tags and helpers
- [`html-tag-name-lowercase`](./html-tag-name-lowercase.md) - Enforces lowercase tag names in HTML

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Linter Rule: No redundant `alt` text on `<img>` tags

**Rule:** `a11y-no-redundant-image-alt`

## Description

Enforce that `<img>` `alt` attributes do not contain the words "image" or "picture".

## Rationale

Screen readers already announce `<img>` elements as images. Including words like "image" or "picture" in the `alt` attribute is redundant and creates a repetitive experience for users relying on assistive technologies (e.g., "image, image of a sunset").

## Examples

### ✅ Good

```erb
<img src="/sunset.jpg" alt="A sunset over the ocean">
```

```erb
<img src="/logo.png" alt="Company logo">
```

```erb
<img src="/decorative.png" alt="">
```

### 🚫 Bad

```erb
<img src="/sunset.jpg" alt="image of a sunset">
```

```erb
<img src="/logo.png" alt="picture of the company logo">
```

## References

- [WCAG 2.1: Non-text Content](https://www.w3.org/WAI/WCAG22/quickref/?versions=2.1#non-text-content)
- [Deque: Alt text should not use the word "image", "picture", or "photo"](https://dequeuniversity.com/rules/axe/4.10/image-redundant-alt)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { RuleClass } from "./types.js"
import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js"
import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js"
import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js"
import { A11yNoRedundantImageAltRule } from "./rules/a11y-no-redundant-image-alt.js"

import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js"
import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js"
Expand Down Expand Up @@ -106,6 +107,7 @@ export const rules: RuleClass[] = [
A11yNoAccesskeyAttributeRule,
A11yNoAriaUnsupportedElementsRule,
A11yNoAutofocusAttributeRule,
A11yNoRedundantImageAltRule,

ActionViewNoSilentHelperRule,
ActionViewNoSilentRenderRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { getAttribute, getAttributeValue, hasAttributeValue, getTagLocalName, isHTMLOpenTagNode, isERBOpenTagNode, filterHTMLAttributeNodes, findAttributeByName, hasDynamicAttributeValue } from "@herb-tools/core"

import { ParserRule } from "../types.js"
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
import type { HTMLElementNode, HTMLAttributeNode, ParseResult, ParserOptions } from "@herb-tools/core"

const REDUNDANT_ALT_WORDS = new Set(["image", "picture"])

class NoRedundantImageAltVisitor extends BaseRuleVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
this.checkImgTag(node)
super.visitHTMLElementNode(node)
}

private checkImgTag(node: HTMLElementNode): void {
const tagName = getTagLocalName(node)

if (tagName !== "img") {
return
}

const altAttribute = this.getAltAttribute(node)

if (!altAttribute) return
if (!hasAttributeValue(altAttribute)) return
if (hasDynamicAttributeValue(altAttribute)) return

const altValue = getAttributeValue(altAttribute)

if (!altValue) return

const words = altValue.toLowerCase().split(/\s+/)

if (words.some(word => REDUNDANT_ALT_WORDS.has(word))) {
this.addOffense(
"`<img>` `alt` prop should not contain \"image\" or \"picture\" as screen readers already announce the element as an image.",
altAttribute.location,
)
}
}

private getAltAttribute(node: HTMLElementNode): HTMLAttributeNode | null {
const openTag = node.open_tag

if (isHTMLOpenTagNode(openTag)) {
return getAttribute(openTag, "alt")
}

if (isERBOpenTagNode(openTag)) {
return findAttributeByName(filterHTMLAttributeNodes(openTag.children), "alt")
}

return null
}
}

export class A11yNoRedundantImageAltRule extends ParserRule {
static ruleName = "a11y-no-redundant-image-alt"
static introducedIn = this.version("unreleased")

get defaultConfig(): FullRuleConfig {
return {
enabled: false,
severity: "warning"
}
}

get parserOptions(): Partial<ParserOptions> {
return { action_view_helpers: true }
}

check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
const visitor = new NoRedundantImageAltVisitor(this.ruleName, context)
visitor.visit(result.value)
return visitor.offenses
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./a11y-no-accesskey-attribute.js"
export * from "./a11y-no-aria-unsupported-elements.js"
export * from "./a11y-no-autofocus-attribute.js"
export * from "./a11y-no-redundant-image-alt.js"

export * from "./rule-utils.js"
export * from "./prism-rule-utils.js"
Expand Down
44 changes: 22 additions & 22 deletions javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading