Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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/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