Skip to content

Commit 37b30b5

Browse files
authored
Linter: Make html-no-duplicate-attributes Action View Helpers aware (#1449)
Now that we have support for detecting Action View Tag helpers we can update the `html-no-duplicate-attributes` linter rule to make it Action View Tag helpers aware. Since the parser already transforms the nodes we only need to add `action_view_helpers: true` to the parser options and handle `ERBOpenTagNode` in the visitor to reset attribute tracking for tag helper elements. With this pull request, the following snippets are now also flagged by the `html-no-duplicate-attributes` linter rule: ```erb <%= tag.div data: { value: "value-one" }, data_value: "value-two" %> <%= tag.div aria: { label: "Label one" }, aria_label: "Label two" %> <%= image_tag image_path("image.png"), src: "image-2.png" %> <%= turbo_frame_tag "my-frame", id: "other-frame" %> <%= link_to "Click here", "/path", href: "/other-path" %> <%= javascript_include_tag "application", src: "other.js" %> ``` For example, the first example now reports as: ``` Duplicate attribute `data-value`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values. ```
1 parent bd698f4 commit 37b30b5

File tree

2 files changed

+94
-2
lines changed

2 files changed

+94
-2
lines changed

javascript/packages/linter/src/rules/html-no-duplicate-attributes.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ControlFlowTrackingVisitor, ControlFlowType } from "./rule-utils.js"
33
import { getAttributeName } from "@herb-tools/core"
44

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

88
interface ControlFlowState {
99
previousBranchAttributes: Set<string>
@@ -24,10 +24,19 @@ class NoDuplicateAttributesVisitor extends ControlFlowTrackingVisitor<
2424
private controlFlowAttributes = new Set<string>()
2525

2626
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
27+
this.resetAttributeSets()
28+
super.visitHTMLOpenTagNode(node)
29+
}
30+
31+
visitERBOpenTagNode(node: ERBOpenTagNode): void {
32+
this.resetAttributeSets()
33+
super.visitERBOpenTagNode(node)
34+
}
35+
36+
private resetAttributeSets(): void {
2737
this.tagAttributes = new Set()
2838
this.currentBranchAttributes = new Set()
2939
this.controlFlowAttributes = new Set()
30-
super.visitHTMLOpenTagNode(node)
3140
}
3241

3342
visitHTMLAttributeNode(node: HTMLAttributeNode): void {
@@ -172,6 +181,10 @@ export class HTMLNoDuplicateAttributesRule extends ParserRule {
172181
}
173182
}
174183

184+
get parserOptions(): Partial<ParserOptions> {
185+
return { action_view_helpers: true }
186+
}
187+
175188
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
176189
const visitor = new NoDuplicateAttributesVisitor(this.ruleName, context)
177190

javascript/packages/linter/test/rules/html-no-duplicate-attributes.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,83 @@ describe("html-no-duplicate-attributes", () => {
291291
></div>
292292
`)
293293
})
294+
295+
// Action View tag helper tests
296+
297+
test("passes for tag helper with unique attributes", () => {
298+
expectNoOffenses('<%= tag.div class: "container", id: "main" %>')
299+
})
300+
301+
test("fails for tag helper with duplicate data attributes via hash and underscore style", () => {
302+
expectError('Duplicate attribute `data-value`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
303+
assertOffenses('<%= tag.div data: { value: "value-one" }, data_value: "value-two" %>')
304+
})
305+
306+
test("fails for tag helper with duplicate aria attributes via hash and underscore style", () => {
307+
expectError('Duplicate attribute `aria-label`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
308+
assertOffenses('<%= tag.div aria: { label: "Label one" }, aria_label: "Label two" %>')
309+
})
310+
311+
test("fails for image_tag helper with duplicate src from positional argument and keyword", () => {
312+
expectError('Duplicate attribute `src`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
313+
assertOffenses('<%= image_tag image_path("image.png"), src: "image-2.png" %>')
314+
})
315+
316+
test("passes for tag helper with non-overlapping data hash and underscore attributes", () => {
317+
expectNoOffenses('<%= tag.div data: { controller: "content" }, data_action: "click" %>')
318+
})
319+
320+
test("fails for tag helper with duplicate class attribute", () => {
321+
expectError('Duplicate attribute `class`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
322+
assertOffenses('<%= tag.div class: "one", class: "two" %>')
323+
})
324+
325+
test("fails for javascript_include_tag with duplicate src from positional argument and keyword", () => {
326+
expectError('Duplicate attribute `src`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
327+
assertOffenses('<%= javascript_include_tag "application", src: "other.js" %>')
328+
})
329+
330+
test("fails for link_to with duplicate href from positional argument and keyword", () => {
331+
expectError('Duplicate attribute `href`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
332+
assertOffenses('<%= link_to "Click here", "/path", href: "/other-path" %>')
333+
})
334+
335+
test("fails for turbo_frame_tag with duplicate id from positional argument and keyword", () => {
336+
expectError('Duplicate attribute `id`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
337+
assertOffenses('<%= turbo_frame_tag "my-frame", id: "other-frame" %>')
338+
})
339+
340+
test("passes for image_tag with unique attributes", () => {
341+
expectNoOffenses('<%= image_tag "logo.png", alt: "Logo", class: "img-fluid" %>')
342+
})
343+
344+
test("passes for link_to with unique attributes", () => {
345+
expectNoOffenses('<%= link_to "Home", root_path, class: "nav-link" %>')
346+
})
347+
348+
test("passes for turbo_frame_tag with unique attributes", () => {
349+
expectNoOffenses('<%= turbo_frame_tag "main", src: "/path", loading: "lazy" %>')
350+
})
351+
352+
test("fails for content_tag with duplicate data attributes via hash and underscore style", () => {
353+
expectError('Duplicate attribute `data-controller`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
354+
assertOffenses('<%= content_tag :div, "content", data: { controller: "one" }, data_controller: "two" %>')
355+
})
356+
357+
test("passes for id in mutually exclusive branches with HTML and Action View tag helper", () => {
358+
expectNoOffenses(dedent`
359+
<% if use_tag_helper? %>
360+
<%= tag.div id: "my-id" do %>
361+
content
362+
<% end %>
363+
<% else %>
364+
<div id="my-id">content</div>
365+
<% end %>
366+
`)
367+
})
368+
369+
test("fails for tag helper with duplicate data attributes from multiple nested hash keys", () => {
370+
expectError('Duplicate attribute `data-action`. Browsers only use the first occurrence and ignore duplicate attributes. Remove the duplicate or merge the values.')
371+
assertOffenses('<%= tag.div data: { controller: "content", action: "click" }, data_action: "hover" %>')
372+
})
294373
})

0 commit comments

Comments
 (0)