Skip to content

Commit d493ba2

Browse files
committed
Linter: Make html-anchor-require-href Action View Helper aware
1 parent 61b5a68 commit d493ba2

File tree

4 files changed

+105
-22
lines changed

4 files changed

+105
-22
lines changed

javascript/packages/linter/docs/rules/html-anchor-require-href.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ Anchor tags without an `href` attribute are not focusable via keyboard navigatio
2424
<a>Go to Page</a>
2525
```
2626

27+
```erb
28+
<a href="#">Go to Page</a>
29+
```
30+
2731
```erb
2832
<a data-action="click->doSomething">I'm a fake link</a>
2933
```

javascript/packages/linter/src/rules/html-anchor-require-href.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
import { BaseRuleVisitor } from "./rule-utils.js"
2-
import { getAttribute, getStaticAttributeValue, getTagLocalName } from "@herb-tools/core"
2+
import { getAttribute, getStaticAttributeValue, getTagLocalName, isERBOpenTagNode, isHTMLOpenTagNode, isRubyLiteralNode, filterHTMLAttributeNodes, findAttributeByName } from "@herb-tools/core"
33

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

88
class AnchorRequireHrefVisitor extends BaseRuleVisitor {
9-
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
9+
visitHTMLElementNode(node: HTMLElementNode): void {
1010
this.checkATag(node)
11-
super.visitHTMLOpenTagNode(node)
11+
super.visitHTMLElementNode(node)
1212
}
1313

14-
private checkATag(node: HTMLOpenTagNode): void {
14+
private checkATag(node: HTMLElementNode): void {
1515
const tagName = getTagLocalName(node)
1616

1717
if (tagName !== "a") {
1818
return
1919
}
2020

21-
const hrefAttribute = getAttribute(node, "href")
21+
const hrefAttribute = this.getHrefAttribute(node)
2222

2323
if (!hrefAttribute) {
2424
this.addOffense(
25-
"Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should go somewhere, you probably want to use a `<button>` instead.",
25+
"Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.",
2626
node.tag_name!.location,
2727
)
2828

@@ -33,10 +33,43 @@ class AnchorRequireHrefVisitor extends BaseRuleVisitor {
3333

3434
if (hrefValue === "#") {
3535
this.addOffense(
36-
"Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should go somewhere, you probably want to use a `<button>` instead.",
37-
node.tag_name!.location,
36+
'Avoid `href="#"` on `<a>`. `href="#"` does not navigate anywhere, scrolls the page to the top, and adds `#` to the URL. If you need a clickable element without navigation, use a `<button>` instead.',
37+
hrefAttribute.location,
3838
)
39+
40+
return
41+
}
42+
43+
if (this.hasNilHrefValue(hrefAttribute)) {
44+
this.addOffense(
45+
"Avoid passing `nil` as the URL for `link_to`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.",
46+
hrefAttribute.location,
47+
)
48+
}
49+
}
50+
51+
private hasNilHrefValue(hrefAttribute: HTMLAttributeNode): boolean {
52+
const valueNode = hrefAttribute.value
53+
54+
if (!valueNode) return false
55+
56+
return valueNode.children.some(child =>
57+
isRubyLiteralNode(child) && child.content === "url_for(nil)"
58+
)
59+
}
60+
61+
private getHrefAttribute(node: HTMLElementNode): HTMLAttributeNode | null {
62+
const openTag = node.open_tag
63+
64+
if (isHTMLOpenTagNode(openTag)) {
65+
return getAttribute(openTag, "href")
66+
}
67+
68+
if (isERBOpenTagNode(openTag)) {
69+
return findAttributeByName(filterHTMLAttributeNodes(openTag.children), "href")
3970
}
71+
72+
return null
4073
}
4174
}
4275

@@ -50,6 +83,12 @@ export class HTMLAnchorRequireHrefRule extends ParserRule {
5083
}
5184
}
5285

86+
get parserOptions(): Partial<ParserOptions> {
87+
return {
88+
action_view_helpers: true,
89+
}
90+
}
91+
5392
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
5493
const visitor = new AnchorRequireHrefVisitor(this.ruleName, context)
5594

javascript/packages/linter/test/__snapshots__/cli.test.ts.snap

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

javascript/packages/linter/test/rules/html-anchor-require-href-rule.test.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,42 @@ import { createLinterTest } from "../helpers/linter-test-helper.js"
44

55
const { expectNoOffenses, expectError, assertOffenses } = createLinterTest(HTMLAnchorRequireHrefRule)
66

7-
const MESSAGE = "Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should go somewhere, you probably want to use a `<button>` instead."
7+
const MISSING_HREF_MESSAGE = "Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead."
8+
const HASH_HREF_MESSAGE = 'Avoid `href="#"` on `<a>`. `href="#"` does not navigate anywhere, scrolls the page to the top, and adds `#` to the URL. If you need a clickable element without navigation, use a `<button>` instead.'
9+
const NIL_HREF_MESSAGE = "Avoid passing `nil` as the URL for `link_to`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead."
810

911
describe("html-anchor-require-href", () => {
1012
test("passes for a with href attribute", () => {
1113
expectNoOffenses('<a href="http://example.com">My link</a>')
1214
})
1315

1416
test("fails for a without href attribute", () => {
15-
expectError(MESSAGE)
17+
expectError(MISSING_HREF_MESSAGE)
1618

1719
assertOffenses("<a>My link</a>")
1820
})
1921

2022
test("fails for multiple a tags without href", () => {
21-
expectError(MESSAGE)
22-
expectError(MESSAGE)
23+
expectError(MISSING_HREF_MESSAGE)
24+
expectError(MISSING_HREF_MESSAGE)
2325

2426
assertOffenses("<a>My link</a><a>My other link</a>")
2527
})
2628

2729
test("fails for a with href='#'", () => {
28-
expectError(MESSAGE)
30+
expectError(HASH_HREF_MESSAGE)
2931

3032
assertOffenses('<a href="#">My link</a>')
3133
})
3234

3335
test("fails for a with name attribute and no href", () => {
34-
expectError(MESSAGE)
36+
expectError(MISSING_HREF_MESSAGE)
3537

3638
assertOffenses('<a name="section1"></a>')
3739
})
3840

3941
test("fails for a with id attribute and no href", () => {
40-
expectError(MESSAGE)
42+
expectError(MISSING_HREF_MESSAGE)
4143

4244
assertOffenses('<a id="content">anchor target</a>')
4345
})
@@ -59,13 +61,13 @@ describe("html-anchor-require-href", () => {
5961
})
6062

6163
test("fails for a with other attributes but no href", () => {
62-
expectError(MESSAGE)
64+
expectError(MISSING_HREF_MESSAGE)
6365

6466
assertOffenses('<a class="btn">My link</a>')
6567
})
6668

6769
test("fails for a with href='#' and other attributes", () => {
68-
expectError(MESSAGE)
70+
expectError(HASH_HREF_MESSAGE)
6971

7072
assertOffenses('<a href="#" data-action="click->doSomething">My link</a>')
7173
})
@@ -75,8 +77,46 @@ describe("html-anchor-require-href", () => {
7577
})
7678

7779
test("handles mixed case a tags", () => {
78-
expectError(MESSAGE)
80+
expectError(MISSING_HREF_MESSAGE)
7981

8082
assertOffenses("<A>My link</A>")
8183
})
84+
85+
test("fails for link_to with href='#'", () => {
86+
expectError(HASH_HREF_MESSAGE)
87+
88+
assertOffenses('<%= link_to "Click me", "#" %>')
89+
})
90+
91+
test("fails for link_to with href='#' and html options", () => {
92+
expectError(HASH_HREF_MESSAGE)
93+
94+
assertOffenses('<%= link_to "Click me", "#", class: "example" %>')
95+
})
96+
97+
test("fails for link_to with href='#' block form", () => {
98+
expectError(HASH_HREF_MESSAGE)
99+
100+
assertOffenses('<%= link_to "#" do %>Click me<% end %>')
101+
})
102+
103+
test("passes for link_to with path helper", () => {
104+
expectNoOffenses('<%= link_to "Click me", root_path %>')
105+
})
106+
107+
test("passes for link_to with string url", () => {
108+
expectNoOffenses('<%= link_to "Click me", "http://example.com" %>')
109+
})
110+
111+
test("fails for link_to with nil url", () => {
112+
expectError(NIL_HREF_MESSAGE)
113+
114+
assertOffenses('<%= link_to "Profile", nil %>')
115+
})
116+
117+
test("fails for link_to with nil url and html options", () => {
118+
expectError(NIL_HREF_MESSAGE)
119+
120+
assertOffenses('<%= link_to "Profile", nil, class: "btn" %>')
121+
})
82122
})

0 commit comments

Comments
 (0)