Skip to content

Commit 5978d9f

Browse files
authored
Linter: Make html-anchor-require-href Action View Helper aware (#1367)
Based on the infrastructure introduced in #1347 we can now start making the linter rules aware of the Action View Tag Helpers. The `html-anchor-require-href` linter rule is now also able to flag the following: ```erb <%= link_to "Home", "#" %> ```
1 parent 61b5a68 commit 5978d9f

File tree

4 files changed

+133
-22
lines changed

4 files changed

+133
-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: 57 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,52 @@ 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,
38+
)
39+
40+
return
41+
}
42+
43+
if (hrefValue !== null && hrefValue.startsWith("javascript:void")) {
44+
this.addOffense(
45+
'Avoid `javascript:void(0)` in `href` on `<a>`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.',
46+
hrefAttribute.location,
3847
)
48+
49+
return
50+
}
51+
52+
if (this.hasNilHrefValue(hrefAttribute)) {
53+
this.addOffense(
54+
"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.",
55+
hrefAttribute.location,
56+
)
57+
}
58+
}
59+
60+
private hasNilHrefValue(hrefAttribute: HTMLAttributeNode): boolean {
61+
const valueNode = hrefAttribute.value
62+
63+
if (!valueNode) return false
64+
65+
return valueNode.children.some(child =>
66+
isRubyLiteralNode(child) && child.content === "url_for(nil)"
67+
)
68+
}
69+
70+
private getHrefAttribute(node: HTMLElementNode): HTMLAttributeNode | null {
71+
const openTag = node.open_tag
72+
73+
if (isHTMLOpenTagNode(openTag)) {
74+
return getAttribute(openTag, "href")
3975
}
76+
77+
if (isERBOpenTagNode(openTag)) {
78+
return findAttributeByName(filterHTMLAttributeNodes(openTag.children), "href")
79+
}
80+
81+
return null
4082
}
4183
}
4284

@@ -50,6 +92,12 @@ export class HTMLAnchorRequireHrefRule extends ParserRule {
5092
}
5193
}
5294

95+
get parserOptions(): Partial<ParserOptions> {
96+
return {
97+
action_view_helpers: true,
98+
}
99+
}
100+
53101
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
54102
const visitor = new AnchorRequireHrefVisitor(this.ruleName, context)
55103

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: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,43 @@ 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 JAVASCRIPT_VOID_HREF_MESSAGE = 'Avoid `javascript:void(0)` in `href` on `<a>`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.'
10+
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."
811

912
describe("html-anchor-require-href", () => {
1013
test("passes for a with href attribute", () => {
1114
expectNoOffenses('<a href="http://example.com">My link</a>')
1215
})
1316

1417
test("fails for a without href attribute", () => {
15-
expectError(MESSAGE)
18+
expectError(MISSING_HREF_MESSAGE)
1619

1720
assertOffenses("<a>My link</a>")
1821
})
1922

2023
test("fails for multiple a tags without href", () => {
21-
expectError(MESSAGE)
22-
expectError(MESSAGE)
24+
expectError(MISSING_HREF_MESSAGE)
25+
expectError(MISSING_HREF_MESSAGE)
2326

2427
assertOffenses("<a>My link</a><a>My other link</a>")
2528
})
2629

2730
test("fails for a with href='#'", () => {
28-
expectError(MESSAGE)
31+
expectError(HASH_HREF_MESSAGE)
2932

3033
assertOffenses('<a href="#">My link</a>')
3134
})
3235

3336
test("fails for a with name attribute and no href", () => {
34-
expectError(MESSAGE)
37+
expectError(MISSING_HREF_MESSAGE)
3538

3639
assertOffenses('<a name="section1"></a>')
3740
})
3841

3942
test("fails for a with id attribute and no href", () => {
40-
expectError(MESSAGE)
43+
expectError(MISSING_HREF_MESSAGE)
4144

4245
assertOffenses('<a id="content">anchor target</a>')
4346
})
@@ -59,13 +62,13 @@ describe("html-anchor-require-href", () => {
5962
})
6063

6164
test("fails for a with other attributes but no href", () => {
62-
expectError(MESSAGE)
65+
expectError(MISSING_HREF_MESSAGE)
6366

6467
assertOffenses('<a class="btn">My link</a>')
6568
})
6669

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

7073
assertOffenses('<a href="#" data-action="click->doSomething">My link</a>')
7174
})
@@ -75,8 +78,64 @@ describe("html-anchor-require-href", () => {
7578
})
7679

7780
test("handles mixed case a tags", () => {
78-
expectError(MESSAGE)
81+
expectError(MISSING_HREF_MESSAGE)
7982

8083
assertOffenses("<A>My link</A>")
8184
})
85+
86+
test("fails for a with href='javascript:void(0)'", () => {
87+
expectError(JAVASCRIPT_VOID_HREF_MESSAGE)
88+
89+
assertOffenses('<a href="javascript:void(0)">My link</a>')
90+
})
91+
92+
test("fails for a with href='javascript:void(0)' and other attributes", () => {
93+
expectError(JAVASCRIPT_VOID_HREF_MESSAGE)
94+
95+
assertOffenses('<a href="javascript:void(0)" data-action="click->doSomething">My link</a>')
96+
})
97+
98+
test("fails for a with href='javascript:void()'", () => {
99+
expectError(JAVASCRIPT_VOID_HREF_MESSAGE)
100+
101+
assertOffenses('<a href="javascript:void()">My link</a>')
102+
})
103+
104+
test("fails for link_to with href='#'", () => {
105+
expectError(HASH_HREF_MESSAGE)
106+
107+
assertOffenses('<%= link_to "Click me", "#" %>')
108+
})
109+
110+
test("fails for link_to with href='#' and html options", () => {
111+
expectError(HASH_HREF_MESSAGE)
112+
113+
assertOffenses('<%= link_to "Click me", "#", class: "example" %>')
114+
})
115+
116+
test("fails for link_to with href='#' block form", () => {
117+
expectError(HASH_HREF_MESSAGE)
118+
119+
assertOffenses('<%= link_to "#" do %>Click me<% end %>')
120+
})
121+
122+
test("passes for link_to with path helper", () => {
123+
expectNoOffenses('<%= link_to "Click me", root_path %>')
124+
})
125+
126+
test("passes for link_to with string url", () => {
127+
expectNoOffenses('<%= link_to "Click me", "http://example.com" %>')
128+
})
129+
130+
test("fails for link_to with nil url", () => {
131+
expectError(NIL_HREF_MESSAGE)
132+
133+
assertOffenses('<%= link_to "Profile", nil %>')
134+
})
135+
136+
test("fails for link_to with nil url and html options", () => {
137+
expectError(NIL_HREF_MESSAGE)
138+
139+
assertOffenses('<%= link_to "Profile", nil, class: "btn" %>')
140+
})
82141
})

0 commit comments

Comments
 (0)