Skip to content

Commit b25ab1b

Browse files
authored
feat: add no-duplicate-class rule (#319)
* temp * feat: add no-duplicate-class rule * impl * fix * Update .cspell.json
1 parent 501ee24 commit b25ab1b

File tree

7 files changed

+275
-3
lines changed

7 files changed

+275
-3
lines changed

.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"packages/eslint-plugin/tests/rules/use-baseline.test.js"
1616
],
1717
"words": [
18+
"foofoo",
1819
"tseslint",
1920
"frontmatter",
2021
"rehype",

docs/rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
| Rule | Description | |
1111
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ---- |
1212
| [no-duplicate-attrs](rules/no-duplicate-attrs) | Disallow to use duplicate attributes ||
13+
| [no-duplicate-class](rules/no-duplicate-class) | Disallow to use duplicate class | 🔧 |
1314
| [no-duplicate-id](rules/no-duplicate-id) | Disallow to use duplicate id ||
1415
| [no-extra-spacing-text](rules/no-extra-spacing-text) | Disallow unnecessary consecutive spaces | 🔧 |
1516
| [no-inline-styles](rules/no-inline-styles) | Disallow using inline style | |

docs/rules/no-duplicate-class.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# no-duplicate-class
2+
3+
This rule disallows duplicate class names in `class` attributes.
4+
5+
## How to use
6+
7+
```js,.eslintrc.js
8+
module.exports = {
9+
rules: {
10+
"@html-eslint/no-duplicate-class": "error",
11+
},
12+
};
13+
```
14+
15+
## Rule Details
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
```html,incorrect
20+
<div class="btn btn primary btn"></div>
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```html,correct
26+
<div class="btn primary"></div>
27+
```

packages/eslint-plugin/lib/rules/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const noNestedInteractive = require("./no-nested-interactive");
4646
const maxElementDepth = require("./max-element-depth");
4747
const requireExplicitSize = require("./require-explicit-size");
4848
const useBaseLine = require("./use-baseline");
49+
const noDuplicateClass = require("./no-duplicate-class");
4950
// import new rule here ↑
5051
// DO NOT REMOVE THIS COMMENT
5152

@@ -98,6 +99,7 @@ module.exports = {
9899
"max-element-depth": maxElementDepth,
99100
"require-explicit-size": requireExplicitSize,
100101
"use-baseline": useBaseLine,
102+
"no-duplicate-class": noDuplicateClass,
101103
// export new rule here ↑
102104
// DO NOT REMOVE THIS COMMENT
103105
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @typedef { import("@html-eslint/types").Tag } Tag
3+
* @typedef { import("@html-eslint/types").StyleTag } StyleTag
4+
* @typedef { import("@html-eslint/types").ScriptTag } ScriptTag
5+
* @typedef { import("@html-eslint/types").AttributeValue } AttributeValue
6+
* @typedef { import("../types").RuleModule<[]> } RuleModule
7+
* @typedef {Object} ClassInfo
8+
* @property {string} name
9+
* @property {import("@html-eslint/types").AnyNode['loc']} loc
10+
* @property {import("@html-eslint/types").AnyNode['range']} range
11+
*/
12+
13+
const { NodeTypes } = require("es-html-parser");
14+
const { RULE_CATEGORY } = require("../constants");
15+
const { createVisitors } = require("./utils/visitors");
16+
17+
const MESSAGE_IDS = {
18+
DUPLICATE_CLASS: "duplicateClass",
19+
};
20+
21+
/**
22+
* @type {RuleModule}
23+
*/
24+
module.exports = {
25+
meta: {
26+
type: "code",
27+
docs: {
28+
description: "Disallow to use duplicate class",
29+
category: RULE_CATEGORY.BEST_PRACTICE,
30+
recommended: false,
31+
},
32+
fixable: "code",
33+
schema: [],
34+
messages: {
35+
[MESSAGE_IDS.DUPLICATE_CLASS]: "The class '{{class}}' is duplicated.",
36+
},
37+
},
38+
39+
create(context) {
40+
/**
41+
* @param {AttributeValue} value
42+
* @returns {{value: string, pos: number}[]}
43+
*/
44+
function splitClassAndSpaces(value) {
45+
/**
46+
* @type {{value: string, pos: number}[]}
47+
*/
48+
const result = [];
49+
const regex = /(\s+|\S+)/g;
50+
/**
51+
* @type {RegExpExecArray | null}
52+
*/
53+
let match = null;
54+
55+
while ((match = regex.exec(value.value)) !== null) {
56+
result.push({
57+
value: match[0],
58+
pos: match.index,
59+
});
60+
}
61+
62+
return result;
63+
}
64+
65+
return createVisitors(context, {
66+
Attribute(node) {
67+
if (node.key.value.toLowerCase() !== "class") {
68+
return;
69+
}
70+
const attributeValue = node.value;
71+
if (
72+
!attributeValue ||
73+
!attributeValue.value ||
74+
attributeValue.parts.some((part) => part.type === NodeTypes.Template)
75+
) {
76+
return;
77+
}
78+
const classesAndSpaces = splitClassAndSpaces(attributeValue);
79+
const classSet = new Set();
80+
classesAndSpaces.forEach(({ value, pos }, index) => {
81+
const className = value.trim();
82+
83+
if (className.length && classSet.has(className)) {
84+
context.report({
85+
loc: {
86+
start: {
87+
line: attributeValue.loc.start.line,
88+
column: attributeValue.loc.start.column + pos,
89+
},
90+
end: {
91+
line: attributeValue.loc.start.line,
92+
column:
93+
attributeValue.loc.start.column + pos + className.length,
94+
},
95+
},
96+
data: {
97+
class: className,
98+
},
99+
messageId: MESSAGE_IDS.DUPLICATE_CLASS,
100+
fix(fixer) {
101+
if (!node.value) {
102+
return null;
103+
}
104+
const before = classesAndSpaces[index - 1];
105+
const after = classesAndSpaces[index + 1];
106+
const hasSpacesBefore =
107+
!!before && before.value.trim().length === 0;
108+
const hasSpacesAfter =
109+
!!after && after.value.trim().length === 0;
110+
const hasClassBefore = !!classesAndSpaces[index - 2];
111+
const hasClassAfter = !!classesAndSpaces[index + 2];
112+
113+
const startRange = hasSpacesBefore
114+
? attributeValue.range[0] + before.pos
115+
: attributeValue.range[0] + pos;
116+
117+
const endRange = hasSpacesAfter
118+
? attributeValue.range[0] +
119+
pos +
120+
value.length +
121+
after.value.length
122+
: attributeValue.range[0] + pos + value.length;
123+
124+
return fixer.replaceTextRange(
125+
[startRange, endRange],
126+
hasClassBefore && hasClassAfter ? " " : ""
127+
);
128+
},
129+
});
130+
} else {
131+
classSet.add(className);
132+
}
133+
});
134+
},
135+
});
136+
},
137+
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const createRuleTester = require("../rule-tester");
2+
const rule = require("../../lib/rules/no-duplicate-class");
3+
4+
const ruleTester = createRuleTester();
5+
const templateRuleTester = createRuleTester("espree");
6+
7+
ruleTester.run("no-duplicate-class", rule, {
8+
valid: [
9+
{
10+
code: `<button class></button>`,
11+
},
12+
{
13+
code: `<button class=foo></button>`,
14+
},
15+
{
16+
code: `<button class="foo"></button>`,
17+
},
18+
{
19+
code: `<button class="foo foofoo"></button>`,
20+
},
21+
{
22+
code: `<button id="foo foo"></button>`,
23+
},
24+
],
25+
invalid: [
26+
{
27+
code: `<button class="foo foo"></button>`,
28+
output: `<button class="foo"></button>`,
29+
errors: [
30+
{
31+
messageId: "duplicateClass",
32+
column: 20,
33+
endColumn: 23,
34+
},
35+
],
36+
},
37+
{
38+
code: `<button class="foo foo"></button>`,
39+
output: `<button class="foo"></button>`,
40+
errors: [
41+
{
42+
messageId: "duplicateClass",
43+
column: 22,
44+
endColumn: 25,
45+
},
46+
],
47+
},
48+
{
49+
code: `<button class="foo bar foo"></button>`,
50+
output: `<button class="foo bar"></button>`,
51+
errors: [
52+
{
53+
messageId: "duplicateClass",
54+
},
55+
],
56+
},
57+
{
58+
code: `<button class="foo foo bar"></button>`,
59+
output: `<button class="foo bar"></button>`,
60+
errors: [
61+
{
62+
messageId: "duplicateClass",
63+
},
64+
],
65+
},
66+
{
67+
code: `<button class=" foo foo bar "></button>`,
68+
output: `<button class=" foo bar "></button>`,
69+
errors: [
70+
{
71+
messageId: "duplicateClass",
72+
},
73+
],
74+
},
75+
],
76+
});
77+
78+
templateRuleTester.run("[template] no-duplicate class", rule, {
79+
valid: [
80+
{
81+
code: "html`<div class='foo'></div>`",
82+
},
83+
{
84+
code: "html`<div class=${' foo foo foo '}></div>`",
85+
},
86+
],
87+
invalid: [
88+
{
89+
code: `
90+
html\`<div class='foo foo'></div>\`
91+
`,
92+
output: `
93+
html\`<div class='foo'></div>\`
94+
`,
95+
errors: [
96+
{
97+
messageId: "duplicateClass",
98+
column: 28,
99+
endColumn: 31,
100+
},
101+
],
102+
},
103+
],
104+
});

packages/website/eslint.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,26 @@ module.exports = [{
2828

2929
rules: {
3030
"@html-eslint/indent": ["error", 2],
31-
3231
"@html-eslint/element-newline": ["error", {
3332
skip: ["pre", "code"],
3433
}],
35-
3634
"@html-eslint/lowercase": "error",
3735
"@html-eslint/no-extra-spacing-attrs": "error",
3836
"@html-eslint/no-multiple-empty-lines": "error",
3937
"@html-eslint/no-trailing-spaces": "error",
4038
"@html-eslint/quotes": "error",
39+
"@html-eslint/no-duplicate-class": "error",
4140
"@stylistic/indent": ["error", 2],
4241
"@stylistic/quote-props": ["error", "as-needed"],
4342
"@stylistic/curly-newline": ["error", "always"],
4443
"@stylistic/padded-blocks": ["error", "never"],
4544
"@stylistic/lines-around-comment": "off",
4645
"@stylistic/space-before-function-paren": ["error", "never"],
4746
"@stylistic/function-call-argument-newline": ["error", "consistent"],
48-
4947
"@stylistic/object-curly-newline": ["error", {
5048
minProperties: 1,
5149
}],
50+
5251
},
5352
}, ...compat.extends("plugin:@html-eslint/recommended").map(config => ({
5453
...config,
@@ -76,5 +75,6 @@ module.exports = [{
7675
"@html-eslint/id-naming-convention": ["error", "kebab-case"],
7776
"@html-eslint/no-multiple-empty-lines": "error",
7877
"@html-eslint/no-trailing-spaces": "error",
78+
"@html-eslint/no-duplicate-class": "error",
7979
},
8080
}];

0 commit comments

Comments
 (0)