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
5 changes: 5 additions & 0 deletions .changeset/add-no-duplicate-attributes-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Added new nursery rule [`noDuplicateAttributes`](https://biomejs.dev/linter/rules/no-duplicate-attributes/) to forbid duplicate attributes in HTML elements.
8 changes: 7 additions & 1 deletion crates/biome_analyze/src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ pub enum RuleSource {
Stylelint(&'static str),
/// Rules from [Eslint Plugin Turbo](https://github.com/vercel/turborepo/tree/main/packages/eslint-plugin-turbo)
EslintTurbo(&'static str),
/// Rules from [html-eslint](https://html-eslint.org/)
HtmlEslint(&'static str),
Copy link
Member

@Netail Netail Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EslintHtml instead perhaps? I think as all of them start with Eslint

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how the project is named though. I think its because eslint-plugin-html is a different thing that only allows linting js in html, and does not implement any html rules itself. It's a bit weird.

}

impl PartialEq for RuleSource {
Expand Down Expand Up @@ -221,6 +223,7 @@ impl std::fmt::Display for RuleSource {
Self::GraphqlSchemaLinter(_) => write!(f, "graphql-schema-linter"),
Self::Stylelint(_) => write!(f, "Stylelint"),
Self::EslintTurbo(_) => write!(f, "eslint-plugin-turbo"),
Self::HtmlEslint(_) => write!(f, "@html-eslint/eslint-plugin"),
}
}
}
Expand Down Expand Up @@ -301,7 +304,8 @@ impl RuleSource {
| Self::EslintVueJs(rule_name)
| Self::GraphqlSchemaLinter(rule_name)
| Self::Stylelint(rule_name)
| Self::EslintTurbo(rule_name) => rule_name,
| Self::EslintTurbo(rule_name)
| Self::HtmlEslint(rule_name) => rule_name,
}
}

Expand Down Expand Up @@ -347,6 +351,7 @@ impl RuleSource {
Self::EslintVitest(rule_name) => format!("vitest/{rule_name}"),
Self::EslintVueJs(rule_name) => format!("vue/{rule_name}"),
Self::EslintTurbo(rule_name) => format!("turbo/{rule_name}"),
Self::HtmlEslint(rule_name) => format!("@html-eslint/{rule_name}"),
}
}

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

Expand Down
4 changes: 4 additions & 0 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/biome_html_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use biome_analyze::declare_lint_group;
pub mod no_ambiguous_anchor_text;
pub mod no_duplicate_attributes;
pub mod no_script_url;
pub mod no_sync_scripts;
pub mod no_vue_v_if_with_v_for;
Expand All @@ -23,4 +24,4 @@ pub mod use_vue_valid_v_once;
pub mod use_vue_valid_v_pre;
pub mod use_vue_valid_v_text;
pub mod use_vue_vapor;
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 ,] } }
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 ,] } }
173 changes: 173 additions & 0 deletions crates/biome_html_analyze/src/lint/nursery/no_duplicate_attributes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
use biome_analyze::{
Ast, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_html_syntax::{AnyHtmlAttribute, AnyVueDirective, HtmlAttributeList};
use biome_rowan::{AstNode, AstNodeList, TextRange, TokenText};
use biome_rule_options::no_duplicate_attributes::NoDuplicateAttributesOptions;
use std::collections::HashSet;

declare_lint_rule! {
/// Disallow duplication of attributes.
///
/// According to the HTML specification, each attribute name must be unique within a single element.
/// Duplicate attributes are invalid and can lead to unexpected behavior in browsers.
///
/// ## Vue templates
///
/// For Vue templates (`.vue` files), this rule also considers the following directives as
/// aliases of their arguments:
///
/// - `v-bind:foo` and `:foo` are handled as the attribute `foo`.
///
/// Vue class/style bindings are ignored. For example, `class` and `:class` may co-exist.
///
/// Event handlers are ignored. For example, `@click` and `v-on:click` are not considered
/// attributes by this rule.
///
/// Dynamic arguments such as `:[foo]` or `v-bind:[foo]` are ignored.
///
/// ## Examples
///
/// ### Invalid
///
/// ```html,expect_diagnostic
/// <div foo="a" foo="b"></div>
/// ```
///
/// ```vue,expect_diagnostic
/// <template>
/// <div foo :foo="bar" />
/// </template>
/// ```
///
/// ### Valid
///
/// ```html
/// <div foo="a" bar="b"></div>
/// ```
///
pub NoDuplicateAttributes {
version: "next",
name: "noDuplicateAttributes",
language: "html",
recommended: true,
sources: &[
RuleSource::HtmlEslint("no-duplicate-attrs").same(),
RuleSource::EslintVueJs("no-duplicate-attributes").same()
],
}
}

pub struct State {
range: TextRange,
name: TokenText,
/// Range of the first occurrence of the attribute.
original_range: TextRange,
}

impl Rule for NoDuplicateAttributes {
type Query = Ast<HtmlAttributeList>;
type State = State;
type Signals = Box<[Self::State]>;
type Options = NoDuplicateAttributesOptions;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let mut seen = HashSet::<(TokenText, TextRange)>::new();
let mut violations = Vec::new();

for attribute in node.iter() {
let Some(key) = attribute_key(&attribute) else {
continue;
};

if let Some((_, original_range)) = seen.iter().find(|(tt, _)| tt == &key.0) {
violations.push(State {
range: attribute.range(),
name: key.0.clone(),
original_range: *original_range,
});
} else {
seen.insert(key);
}
}

violations.into_boxed_slice()
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let name = state.name.text();
Some(
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! {
"Duplicate attribute '"<Emphasis>{name}</Emphasis>"'."
},
)
.detail(state.original_range, "This is the first occurrence of the attribute.")
.note("Each attribute name must be unique within a single element. Duplicate attributes are invalid and can lead to unexpected browser behavior.").note(
markup! {
"Consider removing or renaming the duplicate '"<Emphasis>{name}</Emphasis>"' attribute."
},
),
)
}
}

fn attribute_key(attribute: &AnyHtmlAttribute) -> Option<(TokenText, TextRange)> {
// Plain HTML attribute (eg. `foo`)
if let Some(html_attr) = attribute.as_html_attribute()
&& let Ok(name) = html_attr.name()
&& let Ok(token) = name.value_token()
{
return Some((token.token_text_trimmed(), token.text_trimmed_range()));
}

// Vue directives (`.vue` files only)
let vue = attribute.as_any_vue_directive()?;

match vue {
// Longhand directive: v-bind:foo
AnyVueDirective::VueDirective(directive) => {
let name_token = directive.name_token().ok()?;
let name = name_token.text_trimmed();
if name != "v-bind" {
return None;
}

let argument = directive.arg()?;
let argument = argument.arg().ok()?;
let static_argument = argument.as_vue_static_argument()?;
let name_token = static_argument.name_token().ok()?;

let key = name_token.token_text_trimmed();
if key.text() == "class" || key.text() == "style" {
return None;
}

Some((key, name_token.text_trimmed_range()))
}

// Shorthand bind: :foo
AnyVueDirective::VueVBindShorthandDirective(directive) => {
let argument = directive.arg().ok()?;
let argument = argument.arg().ok()?;
let static_argument = argument.as_vue_static_argument()?;
let name_token = static_argument.name_token().ok()?;

let key = name_token.token_text_trimmed();
if key.text() == "class" || key.text() == "style" {
return None;
}

Some((key, name_token.text_trimmed_range()))
}

// Ignore all v-on and shorthand @event handlers.
AnyVueDirective::VueVOnShorthandDirective(_) => None,

_ => None,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!-- should generate diagnostics -->

<div foo="a" foo="b"></div>

<div Foo Foo></div>

<!-- case-sensitive: these should NOT be considered duplicates -->
<div foo Foo></div>

<div class class></div>
<div style style></div>

Loading