Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e02efe9
feat: add `no-duplicate-class-names` rule
Yizack Sep 8, 2025
f162493
docs: add rule details
Yizack Sep 9, 2025
0fd19ef
docs: add changeset
Yizack Sep 9, 2025
2071c01
feat: report conditional expression
Yizack Sep 9, 2025
99139ad
refactor: no need for a 2nd loop
Yizack Sep 9, 2025
ba9bd9b
feat: support `TemplateLiteral` + improve report
Yizack Sep 10, 2025
535b73a
chore: simplify node param
Yizack Sep 10, 2025
1b04b2a
feat: support `BinaryExpression` + preserve spaces
Yizack Sep 10, 2025
f0c52c0
refactor: rename functions and improve code
Yizack Sep 10, 2025
7b641c0
fix: report cross attribute duplicates
Yizack Sep 18, 2025
a375602
test: add more invalid test cases
Yizack Sep 18, 2025
93e9271
Merge remote-tracking branch 'upstream/master'
Yizack Sep 18, 2025
47b5065
test: rename cross-attribute tests + add literal test
Yizack Sep 18, 2025
c7dcaca
fix: find duplicates within unconditional expression nodes + add comm…
Yizack Sep 18, 2025
b30dbed
Merge remote-tracking branch 'upstream/master'
Yizack Sep 18, 2025
d97f4d9
fix: mixed cross node case
Yizack Sep 18, 2025
a46ec4d
docs: add on more bad and use isActive
Yizack Sep 18, 2025
8e78e75
fix: improve error message
Yizack Oct 15, 2025
a569c82
refactor: refactor variable name
Yizack Oct 15, 2025
142bd0b
docs: pick a more common example for the last "good" example
Yizack Oct 15, 2025
9fec4c9
test: add `line`, `column`, `endLine` and `endColumn`
Yizack Oct 15, 2025
2709da8
Merge remote-tracking branch 'upstream/master'
Yizack Oct 15, 2025
fde5af0
test: adjust error message message
Yizack Oct 15, 2025
7cdcd5d
fix: handle `LogicalExpression`
Yizack Oct 17, 2025
4bb29a1
test: fix duplicate-class-logical-expression-in-conditional
Yizack Oct 17, 2025
d98ca00
test: test actual logical expression duplicate in array
Yizack Oct 17, 2025
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/ten-lines-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-vue': minor
---

Added new [`vue/no-duplicate-class-names`](https://eslint.vuejs.org/rules/no-duplicate-class-names.html) rule
2 changes: 2 additions & 0 deletions docs/rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ For example:
| [vue/no-bare-strings-in-template] | disallow the use of bare strings in `<template>` | | :hammer: |
| [vue/no-boolean-default] | disallow boolean defaults | | :hammer: |
| [vue/no-duplicate-attr-inheritance] | enforce `inheritAttrs` to be set to `false` when using `v-bind="$attrs"` | | :hammer: |
| [vue/no-duplicate-class-names] | disallow duplication of class names in class attributes | :wrench: | :hammer: |
| [vue/no-empty-component-block] | disallow the `<template>` `<script>` `<style>` block to be empty | :wrench: | :hammer: |
| [vue/no-import-compiler-macros] | disallow importing Vue compiler macros | :wrench: | :warning: |
| [vue/no-multiple-objects-in-class] | disallow passing multiple objects in an array to class | | :hammer: |
Expand Down Expand Up @@ -468,6 +469,7 @@ The following rules extend the rules provided by ESLint itself and apply them to
[vue/no-dupe-v-else-if]: ./no-dupe-v-else-if.md
[vue/no-duplicate-attr-inheritance]: ./no-duplicate-attr-inheritance.md
[vue/no-duplicate-attributes]: ./no-duplicate-attributes.md
[vue/no-duplicate-class-names]: ./no-duplicate-class-names.md
[vue/no-empty-component-block]: ./no-empty-component-block.md
[vue/no-empty-pattern]: ./no-empty-pattern.md
[vue/no-export-in-script-setup]: ./no-export-in-script-setup.md
Expand Down
53 changes: 53 additions & 0 deletions docs/rules/no-duplicate-class-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
pageClass: rule-details
sidebarDepth: 0
title: vue/no-duplicate-class-names
description: disallow duplication of class names in class attributes
---

# vue/no-duplicate-class-names

> disallow duplication of class names in class attributes

- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> _**This rule has not been released yet.**_ </badge>
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fix-problems) can automatically fix some of the problems reported by this rule.

## :book: Rule Details

This rule prevents the same class name from appearing multiple times within the same class attribute or directive.

<eslint-code-block fix :rules="{'vue/no-duplicate-class-names': ['error']}">

```vue
<template>
<!-- ✓ GOOD -->
<div class="foo bar"></div>
<div :class="'foo bar'"></div>
<div :class="{ 'foo bar': true }"></div>
<div :class="['foo', 'bar']"></div>
<div :class="isActive ? 'foo' : 'bar'"></div>
<div :class="'foo ' + 'bar'"></div>

<!-- ✗ BAD -->
<div class="foo foo"></div>
<div class="foo bar foo baz bar"></div>
<div :class="'foo foo'"></div>
<div :class="`foo foo`"></div>
<div :class="{ 'foo foo': true }"></div>
<div :class="['foo foo']"></div>
<div :class="['foo foo', { 'bar bar baz': true }]"></div>
<div :class="isActive ? 'foo foo' : 'bar'"></div>
<div :class="'foo foo ' + 'bar'"></div>
</template>
```

</eslint-code-block>

## :wrench: Options

Nothing.

## :mag: Implementation

- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-duplicate-class-names.js)
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/no-duplicate-class-names.js)
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const plugin = {
'no-dupe-v-else-if': require('./rules/no-dupe-v-else-if'),
'no-duplicate-attr-inheritance': require('./rules/no-duplicate-attr-inheritance'),
'no-duplicate-attributes': require('./rules/no-duplicate-attributes'),
'no-duplicate-class-names': require('./rules/no-duplicate-class-names'),
'no-empty-component-block': require('./rules/no-empty-component-block'),
'no-empty-pattern': require('./rules/no-empty-pattern'),
'no-export-in-script-setup': require('./rules/no-export-in-script-setup'),
Expand Down
208 changes: 208 additions & 0 deletions lib/rules/no-duplicate-class-names.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* @fileoverview disallow duplication of class names in class attributes
* @author Yizack Rangel
* See LICENSE file in root directory for full license.
*/
'use strict'

const utils = require('../utils')

/**
* @param {VDirective} node
* @param {Expression} [expression]
* @return {IterableIterator<{ node: Literal | TemplateElement }>}
*/
function* extractClassNodes(node, expression) {
const nodeExpression = expression ?? node.value?.expression
if (!nodeExpression) return

switch (nodeExpression.type) {
case 'Literal': {
yield { node: nodeExpression }
break
}
case 'ObjectExpression': {
for (const prop of nodeExpression.properties) {
if (
prop.type === 'Property' &&
prop.key?.type === 'Literal' &&
typeof prop.key.value === 'string' &&
prop.key.value.includes(' ')
) {
yield { node: prop.key }
}
}
break
}
case 'ArrayExpression': {
for (const element of nodeExpression.elements) {
if (!element || element.type === 'SpreadElement') continue

yield* extractClassNodes(node, element)
}
break
}
case 'ConditionalExpression': {
yield* extractClassNodes(node, nodeExpression.consequent)
yield* extractClassNodes(node, nodeExpression.alternate)
break
}
case 'TemplateLiteral': {
for (const quasi of nodeExpression.quasis) {
yield { node: quasi }
}
for (const expr of nodeExpression.expressions) {
yield* extractClassNodes(node, expr)
}
break
}
case 'BinaryExpression': {
if (nodeExpression.operator === '+') {
yield* extractClassNodes(node, nodeExpression.left)
yield* extractClassNodes(node, nodeExpression.right)
}
break
}
}
}

/**
* @param {string} raw - raw class names string including quotes
* @returns {string}
*/
function removeDuplicateClassNames(raw) {
const quote = raw[0]
const inner = raw.slice(1, -1)
const tokens = inner.split(/(\s+)/)

/** @type {string[]} */
const kept = []
const used = new Set()

for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (!token) continue

const isWhitespace = /^\s+$/.test(token)

if (isWhitespace) {
// add whitespace to the last kept item or as leading whitespace
if (kept.length > 0) {
kept[kept.length - 1] += token
} else {
kept.push(token)
}
} else if (used.has(token)) {
// handle duplicate class name
const nextToken = tokens[i + 1]
const hasNextWhitespace =
kept.length > 0 && i + 1 < tokens.length && /^\s+$/.test(nextToken)

if (hasNextWhitespace) {
// update spaces of the last non-whitespace item
for (let j = kept.length - 1; j >= 0; j--) {
const isNotWhitespace = !/^\s+$/.test(kept[j])
if (isNotWhitespace) {
const parts = kept[j].split(/(\s+)/)
kept[j] = parts[0] + nextToken
break
}
}
i++ // skip the whitespace token
}
} else {
kept.push(token)
used.add(token)
}
}

// remove trailing whitespace from the last item if it's not purely whitespace
// unless the original string ended with whitespace
const endsWithSpace = /\s$/.test(inner)
if (kept.length > 0 && !endsWithSpace) {
const lastItem = kept[kept.length - 1]
const isLastWhitespace = /^\s+$/.test(lastItem)
if (!isLastWhitespace) {
const parts = lastItem.split(/(\s+)/)
kept[kept.length - 1] = parts[0]
}
}

return quote + kept.join('') + quote
}

module.exports = {
meta: {
type: 'suggestion',
docs: {
url: 'https://eslint.vuejs.org/rules/no-duplicate-class-names.html',
description: 'disallow duplication of class names in class attributes',
categories: undefined
},
fixable: 'code',
schema: [],
messages: {
duplicateClassName: "Duplicate class name '{{name}}'."
}
},
/** @param {RuleContext} context */
create: (context) => {
/**
* @param {VLiteral | Literal | TemplateElement | null} node
*/
function reportDuplicateClasses(node) {
if (!node?.value) return

const classList =
typeof node.value === 'object' && 'raw' in node.value
? node.value.raw
: node.value

if (typeof classList !== 'string') return

const classNames = classList.split(/\s+/).filter(Boolean)
if (classNames.length <= 1) return

const seen = new Set()
const duplicates = new Set()

for (const className of classNames) {
if (seen.has(className)) {
duplicates.add(className)
} else {
seen.add(className)
}
}

if (duplicates.size === 0) return

context.report({
node,
messageId: 'duplicateClassName',
data: { name: [...duplicates].join(', ') },
fix: (fixer) => {
const sourceCode = context.getSourceCode()
const raw = sourceCode.text.slice(node.range[0], node.range[1])
return fixer.replaceText(node, removeDuplicateClassNames(raw))
}
})
}

return utils.defineTemplateBodyVisitor(context, {
/** @param {VAttribute} node */
"VAttribute[directive=false][key.name='class'][value.type='VLiteral']"(
node
) {
reportDuplicateClasses(node.value)
},
/** @param {VDirective} node */
"VAttribute[directive=true][key.argument.name='class'][value.type='VExpressionContainer']"(
node
) {
for (const { node: reportNode } of extractClassNodes(node)) {
reportDuplicateClasses(reportNode)
}
}
})
}
}
Loading