Skip to content

Commit 047576d

Browse files
authored
feat(analyze/html): add noDuplicateAttributes (#8653)
1 parent 99611c6 commit 047576d

18 files changed

Lines changed: 599 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added new nursery rule [`noDuplicateAttributes`](https://biomejs.dev/linter/rules/no-duplicate-attributes/) to forbid duplicate attributes in HTML elements.

crates/biome_analyze/src/rule.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ pub enum RuleSource {
170170
Stylelint(&'static str),
171171
/// Rules from [Eslint Plugin Turbo](https://github.com/vercel/turborepo/tree/main/packages/eslint-plugin-turbo)
172172
EslintTurbo(&'static str),
173+
/// Rules from [html-eslint](https://html-eslint.org/)
174+
HtmlEslint(&'static str),
173175
}
174176

175177
impl PartialEq for RuleSource {
@@ -221,6 +223,7 @@ impl std::fmt::Display for RuleSource {
221223
Self::GraphqlSchemaLinter(_) => write!(f, "graphql-schema-linter"),
222224
Self::Stylelint(_) => write!(f, "Stylelint"),
223225
Self::EslintTurbo(_) => write!(f, "eslint-plugin-turbo"),
226+
Self::HtmlEslint(_) => write!(f, "@html-eslint/eslint-plugin"),
224227
}
225228
}
226229
}
@@ -301,7 +304,8 @@ impl RuleSource {
301304
| Self::EslintVueJs(rule_name)
302305
| Self::GraphqlSchemaLinter(rule_name)
303306
| Self::Stylelint(rule_name)
304-
| Self::EslintTurbo(rule_name) => rule_name,
307+
| Self::EslintTurbo(rule_name)
308+
| Self::HtmlEslint(rule_name) => rule_name,
305309
}
306310
}
307311

@@ -347,6 +351,7 @@ impl RuleSource {
347351
Self::EslintVitest(rule_name) => format!("vitest/{rule_name}"),
348352
Self::EslintVueJs(rule_name) => format!("vue/{rule_name}"),
349353
Self::EslintTurbo(rule_name) => format!("turbo/{rule_name}"),
354+
Self::HtmlEslint(rule_name) => format!("@html-eslint/{rule_name}"),
350355
}
351356
}
352357

@@ -388,6 +393,7 @@ impl RuleSource {
388393
Self::GraphqlSchemaLinter(rule_name) => format!("https://github.com/cjoudrey/graphql-schema-linter?tab=readme-ov-file#{rule_name}"),
389394
Self::Stylelint(rule_name) => format!("https://github.com/stylelint/stylelint/blob/main/lib/rules/{rule_name}/README.md"),
390395
Self::EslintTurbo(rule_name) => format!("https://github.com/vercel/turborepo/blob/main/packages/eslint-plugin-turbo/docs/rules/{rule_name}.md"),
396+
Self::HtmlEslint(rule_name) => format!("https://html-eslint.org/docs/rules/{rule_name}"),
391397
}
392398
}
393399

crates/biome_configuration/src/analyzer/linter/rules.rs

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

crates/biome_diagnostics_categories/src/categories.rs

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

crates/biome_html_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
use biome_analyze::declare_lint_group;
66
pub mod no_ambiguous_anchor_text;
7+
pub mod no_duplicate_attributes;
78
pub mod no_script_url;
89
pub mod no_sync_scripts;
910
pub mod no_vue_v_if_with_v_for;
@@ -23,4 +24,4 @@ pub mod use_vue_valid_v_once;
2324
pub mod use_vue_valid_v_pre;
2425
pub mod use_vue_valid_v_text;
2526
pub mod use_vue_vapor;
26-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_consistent_v_bind_style :: UseVueConsistentVBindStyle , self :: use_vue_consistent_v_on_style :: UseVueConsistentVOnStyle , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_v_for_key :: UseVueVForKey , self :: use_vue_valid_template_root :: UseVueValidTemplateRoot , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_cloak :: UseVueValidVCloak , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_once :: UseVueValidVOnce , self :: use_vue_valid_v_pre :: UseVueValidVPre , self :: use_vue_valid_v_text :: UseVueValidVText , self :: use_vue_vapor :: UseVueVapor ,] } }
27+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_duplicate_attributes :: NoDuplicateAttributes , self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_consistent_v_bind_style :: UseVueConsistentVBindStyle , self :: use_vue_consistent_v_on_style :: UseVueConsistentVOnStyle , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_v_for_key :: UseVueVForKey , self :: use_vue_valid_template_root :: UseVueValidTemplateRoot , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_cloak :: UseVueValidVCloak , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_once :: UseVueValidVOnce , self :: use_vue_valid_v_pre :: UseVueValidVPre , self :: use_vue_valid_v_text :: UseVueValidVText , self :: use_vue_vapor :: UseVueVapor ,] } }
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_html_syntax::{AnyHtmlAttribute, AnyVueDirective, HtmlAttributeList};
6+
use biome_rowan::{AstNode, AstNodeList, TextRange, TokenText};
7+
use biome_rule_options::no_duplicate_attributes::NoDuplicateAttributesOptions;
8+
use std::collections::HashSet;
9+
10+
declare_lint_rule! {
11+
/// Disallow duplication of attributes.
12+
///
13+
/// According to the HTML specification, each attribute name must be unique within a single element.
14+
/// Duplicate attributes are invalid and can lead to unexpected behavior in browsers.
15+
///
16+
/// ## Vue templates
17+
///
18+
/// For Vue templates (`.vue` files), this rule also considers the following directives as
19+
/// aliases of their arguments:
20+
///
21+
/// - `v-bind:foo` and `:foo` are handled as the attribute `foo`.
22+
///
23+
/// Vue class/style bindings are ignored. For example, `class` and `:class` may co-exist.
24+
///
25+
/// Event handlers are ignored. For example, `@click` and `v-on:click` are not considered
26+
/// attributes by this rule.
27+
///
28+
/// Dynamic arguments such as `:[foo]` or `v-bind:[foo]` are ignored.
29+
///
30+
/// ## Examples
31+
///
32+
/// ### Invalid
33+
///
34+
/// ```html,expect_diagnostic
35+
/// <div foo="a" foo="b"></div>
36+
/// ```
37+
///
38+
/// ```vue,expect_diagnostic
39+
/// <template>
40+
/// <div foo :foo="bar" />
41+
/// </template>
42+
/// ```
43+
///
44+
/// ### Valid
45+
///
46+
/// ```html
47+
/// <div foo="a" bar="b"></div>
48+
/// ```
49+
///
50+
pub NoDuplicateAttributes {
51+
version: "next",
52+
name: "noDuplicateAttributes",
53+
language: "html",
54+
recommended: true,
55+
sources: &[
56+
RuleSource::HtmlEslint("no-duplicate-attrs").same(),
57+
RuleSource::EslintVueJs("no-duplicate-attributes").same()
58+
],
59+
}
60+
}
61+
62+
pub struct State {
63+
range: TextRange,
64+
name: TokenText,
65+
/// Range of the first occurrence of the attribute.
66+
original_range: TextRange,
67+
}
68+
69+
impl Rule for NoDuplicateAttributes {
70+
type Query = Ast<HtmlAttributeList>;
71+
type State = State;
72+
type Signals = Box<[Self::State]>;
73+
type Options = NoDuplicateAttributesOptions;
74+
75+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
76+
let node = ctx.query();
77+
let mut seen = HashSet::<(TokenText, TextRange)>::new();
78+
let mut violations = Vec::new();
79+
80+
for attribute in node.iter() {
81+
let Some(key) = attribute_key(&attribute) else {
82+
continue;
83+
};
84+
85+
if let Some((_, original_range)) = seen.iter().find(|(tt, _)| tt == &key.0) {
86+
violations.push(State {
87+
range: attribute.range(),
88+
name: key.0.clone(),
89+
original_range: *original_range,
90+
});
91+
} else {
92+
seen.insert(key);
93+
}
94+
}
95+
96+
violations.into_boxed_slice()
97+
}
98+
99+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
100+
let name = state.name.text();
101+
Some(
102+
RuleDiagnostic::new(
103+
rule_category!(),
104+
state.range,
105+
markup! {
106+
"Duplicate attribute '"<Emphasis>{name}</Emphasis>"'."
107+
},
108+
)
109+
.detail(state.original_range, "This is the first occurrence of the attribute.")
110+
.note("Each attribute name must be unique within a single element. Duplicate attributes are invalid and can lead to unexpected browser behavior.").note(
111+
markup! {
112+
"Consider removing or renaming the duplicate '"<Emphasis>{name}</Emphasis>"' attribute."
113+
},
114+
),
115+
)
116+
}
117+
}
118+
119+
fn attribute_key(attribute: &AnyHtmlAttribute) -> Option<(TokenText, TextRange)> {
120+
// Plain HTML attribute (eg. `foo`)
121+
if let Some(html_attr) = attribute.as_html_attribute()
122+
&& let Ok(name) = html_attr.name()
123+
&& let Ok(token) = name.value_token()
124+
{
125+
return Some((token.token_text_trimmed(), token.text_trimmed_range()));
126+
}
127+
128+
// Vue directives (`.vue` files only)
129+
let vue = attribute.as_any_vue_directive()?;
130+
131+
match vue {
132+
// Longhand directive: v-bind:foo
133+
AnyVueDirective::VueDirective(directive) => {
134+
let name_token = directive.name_token().ok()?;
135+
let name = name_token.text_trimmed();
136+
if name != "v-bind" {
137+
return None;
138+
}
139+
140+
let argument = directive.arg()?;
141+
let argument = argument.arg().ok()?;
142+
let static_argument = argument.as_vue_static_argument()?;
143+
let name_token = static_argument.name_token().ok()?;
144+
145+
let key = name_token.token_text_trimmed();
146+
if key.text() == "class" || key.text() == "style" {
147+
return None;
148+
}
149+
150+
Some((key, name_token.text_trimmed_range()))
151+
}
152+
153+
// Shorthand bind: :foo
154+
AnyVueDirective::VueVBindShorthandDirective(directive) => {
155+
let argument = directive.arg().ok()?;
156+
let argument = argument.arg().ok()?;
157+
let static_argument = argument.as_vue_static_argument()?;
158+
let name_token = static_argument.name_token().ok()?;
159+
160+
let key = name_token.token_text_trimmed();
161+
if key.text() == "class" || key.text() == "style" {
162+
return None;
163+
}
164+
165+
Some((key, name_token.text_trimmed_range()))
166+
}
167+
168+
// Ignore all v-on and shorthand @event handlers.
169+
AnyVueDirective::VueVOnShorthandDirective(_) => None,
170+
171+
_ => None,
172+
}
173+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!-- should generate diagnostics -->
2+
3+
<div foo="a" foo="b"></div>
4+
5+
<div Foo Foo></div>
6+
7+
<!-- case-sensitive: these should NOT be considered duplicates -->
8+
<div foo Foo></div>
9+
10+
<div class class></div>
11+
<div style style></div>
12+

0 commit comments

Comments
 (0)