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
36 changes: 33 additions & 3 deletions javascript/packages/core/src/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ import {
isHTMLCommentNode,
isHTMLElementNode,
isHTMLOpenTagNode,
isERBOpenTagNode,
isHTMLTextNode,
isWhitespaceNode,
isHTMLAttributeNameNode,
isHTMLAttributeValueNode,
areAllOfType,
filterLiteralNodes,
filterHTMLTextNodes,
filterHTMLAttributeNodes
} from "./node-type-guards.js"

Expand Down Expand Up @@ -152,6 +154,20 @@ export function getValidatableStaticContent(nodes: Node[]): string | null {
return filterLiteralNodes(nodes).map(node => node.content).join("")
}

/**
* Gets static text content from element body nodes (includes both LiteralNode and HTMLTextNode)
* Returns concatenated text content for validation, or null if contains output ERB
*/
export function getStaticBodyText(nodes: Node[]): string | null {
if (hasERBOutput(nodes)) {
return null
}

const textNodes = [...filterLiteralNodes(nodes), ...filterHTMLTextNodes(nodes)]

return textNodes.map(node => node.content).join("")
}

/**
* Extracts a combined string from nodes, including ERB content
* For ERB nodes, includes the full tag syntax (e.g., "<%= foo %>")
Expand Down Expand Up @@ -263,13 +279,27 @@ export function getOpenTag(node: HTMLElementNode | HTMLOpenTagNode | null | unde
return null
}

/**
* Gets the children array from an element's open tag, regardless of whether
* it's an HTMLOpenTagNode or ERBOpenTagNode (used by action view helpers).
*/
function getOpenTagChildren(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): Node[] {
if (!node) return []
if (isHTMLOpenTagNode(node)) return node.children

if (isHTMLElementNode(node) && node.open_tag) {
if (isHTMLOpenTagNode(node.open_tag)) return node.open_tag.children
if (isERBOpenTagNode(node.open_tag)) return node.open_tag.children
}

return []
}

/**
* Gets attributes from an HTMLElementNode or HTMLOpenTagNode
*/
export function getAttributes(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLAttributeNode[] {
const openTag = getOpenTag(node)

return openTag ? filterHTMLAttributeNodes(openTag.children) : []
return filterHTMLAttributeNodes(getOpenTagChildren(node))
}

/**
Expand Down
1 change: 1 addition & 0 deletions javascript/packages/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This page contains documentation for all Herb Linter rules.

#### Accessibility

- [`a11y-avoid-generic-link-text`](./a11y-avoid-generic-link-text.md) - Avoid generic link text like "Click here" or "Read more"
- [`a11y-disabled-attribute`](./a11y-disabled-attribute.md) - Prevent usage of the `disabled` attribute on unsupported elements
- [`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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Linter Rule: Avoid generic link text

**Rule:** `a11y-avoid-generic-link-text`

## Description

Avoid setting generic link text like "Click here", "Read more", and "Learn more" which do not make sense when read out of context.

## Rationale

Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context. Links should clearly describe their destination.

## Banned generic text

- `Read more`
- `Learn more`
- `Click here`
- `More`
- `Link`
- `Here`

## Examples

### ✅ Good

```erb
<a href="github.com/about">Learn more about GitHub</a>
```

```erb
<a href="github.com/new">Create a new repository</a>
```

```erb
<a aria-label="Learn more about GitHub" href="github.com/about">Learn more</a>
```

```erb
<a aria-labelledby="desc" href="github.com/about">Learn more</a>
```

### 🚫 Bad

```erb
<a href="github.com/about">Learn more</a>
```

```erb
<a href="github.com/about">Read more</a>
```

```erb
<a href="github.com/new">Click here</a>
```

```erb
<a href="github.com">More</a>
```

```erb
<a href="github.com">Link</a>
```

```erb
<a href="github.com">Here</a>
```

## References

- [erblint-github: AvoidGenericLinkText](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/avoid_generic_link_text.rb)
- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/avoid-generic-link-text.md)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RuleClass } from "./types.js"

import { A11yAvoidGenericLinkTextRule } from "./rules/a11y-avoid-generic-link-text.js"
import { A11yDisabledAttributeRule } from "./rules/a11y-disabled-attribute.js"
import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js"
import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js"
Expand Down Expand Up @@ -105,6 +106,7 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio
import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js"

export const rules: RuleClass[] = [
A11yAvoidGenericLinkTextRule,
A11yDisabledAttributeRule,
A11yNoAccesskeyAttributeRule,
A11yNoAriaUnsupportedElementsRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { BaseRuleVisitor } from "./rule-utils.js"
import { getTagLocalName, hasAttribute, getStaticBodyText } from "@herb-tools/core"

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

const BANNED_GENERIC_TEXT = [
"read more",
"learn more",
"click here",
"more",
"link",
"here",
]

function stripText(text: string): string {
return text.toLowerCase().replace(/\W+/g, " ").trim()
}

function isBannedText(text: string): boolean {
return BANNED_GENERIC_TEXT.includes(stripText(text))
}

class AvoidGenericLinkTextVisitor extends BaseRuleVisitor {
visitHTMLElementNode(node: HTMLElementNode): void {
this.checkLinkElement(node)
super.visitHTMLElementNode(node)
}

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

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

if (hasAttribute(node, "aria-labelledby")) {
return
}

if (hasAttribute(node, "aria-label")) {
return
}

const textContent = node.body ? getStaticBodyText(node.body) : ""

if (textContent === null) {
return
}

if (isBannedText(textContent)) {
this.addOffense(
`Avoid using generic link text such as "${textContent.trim()}". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.`,
node.location,
)
}
}
}

export class A11yAvoidGenericLinkTextRule extends ParserRule {
static ruleName = "a11y-avoid-generic-link-text"
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 AvoidGenericLinkTextVisitor(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,3 +1,4 @@
export * from "./a11y-avoid-generic-link-text.js"
export * from "./a11y-disabled-attribute.js"
export * from "./a11y-no-accesskey-attribute.js"
export * from "./a11y-no-aria-unsupported-elements.js"
Expand Down
Loading
Loading