Skip to content

Commit 97a1adb

Browse files
authored
fix: improve attr-value-no-duplication logic (#1668)
* fix: improve attr-value-no-duplication logic * Update tagname-lowercase.mdx
1 parent 702ebca commit 97a1adb

File tree

8 files changed

+139
-127
lines changed

8 files changed

+139
-127
lines changed

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "htmlhint",
3-
"version": "1.6.2",
3+
"version": "1.6.3",
44
"description": "The Static Code Analysis Tool for your HTML",
55
"keywords": [
66
"html",

src/core/rules/attr-value-no-duplication.ts

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,62 +2,52 @@ import { Rule } from '../types'
22

33
export default {
44
id: 'attr-value-no-duplication',
5-
description: 'Attribute values should not contain duplicates.',
6-
init(parser, reporter) {
5+
description:
6+
'Class attributes should not contain duplicate values. Other attributes can be checked via configuration.',
7+
init(parser, reporter, options) {
8+
// Default attributes to check - by default, only check class
9+
const defaultAttributesToCheck = ['class']
10+
11+
// Allow custom configuration of attributes to check
12+
const attributesToCheck = Array.isArray(options)
13+
? options
14+
: defaultAttributesToCheck
15+
716
parser.addListener('tagstart', (event) => {
817
const attrs = event.attrs
918
let attr
1019
const col = event.col + event.tagName.length + 1
1120

12-
// Skip SVG elements entirely
13-
if (event.tagName.toLowerCase() === 'svg') {
14-
return
15-
}
16-
1721
for (let i = 0, l = attrs.length; i < l; i++) {
1822
attr = attrs[i]
23+
const attrName = attr.name.toLowerCase()
1924

20-
// Skip content, media, and style attributes entirely
21-
if (
22-
attr.name.toLowerCase() === 'content' ||
23-
attr.name.toLowerCase() === 'd' ||
24-
attr.name.toLowerCase() === 'media' ||
25-
attr.name.toLowerCase() === 'sizes' ||
26-
attr.name.toLowerCase() === 'src' ||
27-
attr.name.toLowerCase() === 'style' ||
28-
attr.name.toLowerCase().startsWith('on') // Skip all event handlers (onclick, onchange, etc.)
29-
) {
25+
// Strict check - only process attributes in our allowlist
26+
if (!attributesToCheck.includes(attrName)) {
3027
continue
3128
}
3229

33-
if (attr.value) {
34-
let values: string[]
35-
if (attr.name.toLowerCase() === 'media') {
36-
// For media, treat each comma-separated part as a whole
37-
values = attr.value
38-
.split(',')
39-
.map((part) => part.trim())
40-
.filter(Boolean)
41-
} else {
42-
// For other attributes, split by whitespace only
43-
values = attr.value.trim().split(/\s+/)
44-
}
30+
// Only process attributes with values containing spaces
31+
if (!attr.value || !/\s/.test(attr.value)) {
32+
continue
33+
}
4534

46-
const duplicateMap: { [value: string]: boolean } = {}
35+
// Split by whitespace - this is appropriate for class, id, role, etc.
36+
const values = attr.value.trim().split(/\s+/)
37+
const duplicateMap: { [value: string]: boolean } = {}
4738

48-
for (const value of values) {
49-
if (duplicateMap[value] === true) {
50-
reporter.error(
51-
`Duplicate value [ ${value} ] was found in attribute [ ${attr.name} ].`,
52-
event.line,
53-
col + attr.index,
54-
this,
55-
attr.raw
56-
)
57-
break // Only report the first duplicate found per attribute
58-
}
59-
duplicateMap[value] = true
39+
for (const value of values) {
40+
if (value && duplicateMap[value] === true) {
41+
reporter.error(
42+
`Duplicate value [ ${value} ] was found in attribute [ ${attr.name} ].`,
43+
event.line,
44+
col + attr.index,
45+
this,
46+
attr.raw
47+
)
48+
break // Only report the first duplicate found per attribute
6049
}
50+
duplicateMap[value] = true
6151
}
6252
}
6353
})

test/rules/attr-value-no-duplication.spec.js

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,28 @@ describe(`Rules: ${ruleId}`, () => {
1818
)
1919
})
2020

21-
it('Duplicate values in data attribute should result in an error', () => {
21+
it('Duplicate values in id attribute should NOT result in an error with default config', () => {
22+
const code = '<div id="section1 main section1">Test</div>'
23+
const messages = HTMLHint.verify(code, ruleOptions)
24+
expect(messages.length).toBe(0)
25+
})
26+
27+
it('Duplicate values in role attribute should NOT result in an error with default config', () => {
28+
const code = '<div role="button navigation button">Test</div>'
29+
const messages = HTMLHint.verify(code, ruleOptions)
30+
expect(messages.length).toBe(0)
31+
})
32+
33+
it('Duplicate values in name attribute should NOT result in an error with default config', () => {
34+
const code = '<input name="username form1 username">'
35+
const messages = HTMLHint.verify(code, ruleOptions)
36+
expect(messages.length).toBe(0)
37+
})
38+
39+
it('Duplicate values in data attribute should not result in an error with default config', () => {
2240
const code = '<span data-attributes="dark light dark">Test</span>'
2341
const messages = HTMLHint.verify(code, ruleOptions)
24-
expect(messages.length).toBe(1)
25-
expect(messages[0].rule.id).toBe(ruleId)
26-
expect(messages[0].line).toBe(1)
27-
expect(messages[0].col).toBe(6)
28-
expect(messages[0].message).toBe(
29-
'Duplicate value [ dark ] was found in attribute [ data-attributes ].'
30-
)
42+
expect(messages.length).toBe(0)
3143
})
3244

3345
it('No duplicate values should not result in an error', () => {
@@ -65,58 +77,65 @@ describe(`Rules: ${ruleId}`, () => {
6577
)
6678
})
6779

68-
it('SVG elements should be skipped entirely', () => {
69-
const code = '<svg class="icon icon icon" viewBox="0 0 24 24"></svg>'
70-
const messages = HTMLHint.verify(code, ruleOptions)
71-
expect(messages.length).toBe(0)
72-
})
73-
74-
it('Style attributes should be skipped entirely', () => {
80+
it('Angular directive attributes should not result in an error', () => {
7581
const code =
76-
'<div style="width: 2rem; height: 2rem; width: 2rem;">Test</div>'
82+
'<div [ngClass]="\'btn btn\'" *ngIf="condition condition">Test</div>'
7783
const messages = HTMLHint.verify(code, ruleOptions)
7884
expect(messages.length).toBe(0)
7985
})
8086

81-
it('CSS media queries with commas should not be flagged as duplicates', () => {
82-
const code =
83-
'<link rel="stylesheet" href="css/test.css" media="all and (-ms-high-contrast: active), (-ms-high-contrast: none)">'
87+
it('Alt attributes with duplicate words should not result in an error', () => {
88+
// This has the word "a" repeated multiple times which would trigger an error if 'alt' was checked
89+
const code = '<img src="image.jpg" alt="A cat and a dog and a ball">'
8490
const messages = HTMLHint.verify(code, ruleOptions)
8591
expect(messages.length).toBe(0)
8692
})
8793

88-
it('Media attribute with actual duplicates should be skipped', () => {
89-
const code =
90-
'<link rel="stylesheet" href="css/test.css" media="screen screen">'
91-
const messages = HTMLHint.verify(code, ruleOptions)
92-
expect(messages.length).toBe(0)
93-
})
94+
it('Custom attribute configuration should work as expected', () => {
95+
const customOptions = {}
96+
customOptions[ruleId] = ['data-test', 'aria-label']
9497

95-
it('Content attribute should be skipped entirely', () => {
96-
const code =
97-
'<meta name="keywords" content="HTML, CSS, JavaScript, HTML, responsive design">'
98-
const messages = HTMLHint.verify(code, ruleOptions)
99-
expect(messages.length).toBe(0)
100-
})
98+
// This should now trigger an error with our custom config
99+
const code = '<div data-test="unit test unit">Test</div>'
100+
const messages = HTMLHint.verify(code, customOptions)
101+
expect(messages.length).toBe(1)
102+
expect(messages[0].rule.id).toBe(ruleId)
103+
expect(messages[0].message).toBe(
104+
'Duplicate value [ unit ] was found in attribute [ data-test ].'
105+
)
101106

102-
it('Script src attribute should be skipped entirely', () => {
103-
const code =
104-
'<script src="data:text/javascript,window.analytics = window.analytics || function() { (window.analytics.q = window.analytics.q || []).push(arguments) }"></script>'
105-
const messages = HTMLHint.verify(code, ruleOptions)
106-
expect(messages.length).toBe(0)
107+
// Class should no longer be checked with custom config
108+
const code2 = '<div class="btn primary btn">Test</div>'
109+
const messages2 = HTMLHint.verify(code2, customOptions)
110+
expect(messages2.length).toBe(0)
107111
})
108112

109-
it('Sizes attribute should be skipped entirely', () => {
110-
const code =
111-
'<source type="" sizes="(min-width: 1rem) 1vw, (min-width: 2rem) 2vw" srcset="">'
112-
const messages = HTMLHint.verify(code, ruleOptions)
113-
expect(messages.length).toBe(0)
114-
})
113+
it('Extended custom configuration should work as expected', () => {
114+
const extendedOptions = {}
115+
extendedOptions[ruleId] = ['class', 'id', 'role', 'name']
115116

116-
it('Event handler attributes should be skipped entirely', () => {
117-
const code =
118-
"<button onclick=\"trackEvent('click'); validateForm(); trackEvent('click');\">Submit</button>"
119-
const messages = HTMLHint.verify(code, ruleOptions)
120-
expect(messages.length).toBe(0)
117+
// Class should still be checked
118+
const code1 = '<div class="btn primary btn">Test</div>'
119+
const messages1 = HTMLHint.verify(code1, extendedOptions)
120+
expect(messages1.length).toBe(1)
121+
expect(messages1[0].message).toBe(
122+
'Duplicate value [ btn ] was found in attribute [ class ].'
123+
)
124+
125+
// Id should now be checked
126+
const code2 = '<div id="section1 main section1">Test</div>'
127+
const messages2 = HTMLHint.verify(code2, extendedOptions)
128+
expect(messages2.length).toBe(1)
129+
expect(messages2[0].message).toBe(
130+
'Duplicate value [ section1 ] was found in attribute [ id ].'
131+
)
132+
133+
// Role should now be checked
134+
const code3 = '<div role="button navigation button">Test</div>'
135+
const messages3 = HTMLHint.verify(code3, extendedOptions)
136+
expect(messages3.length).toBe(1)
137+
expect(messages3[0].message).toBe(
138+
'Duplicate value [ button ] was found in attribute [ role ].'
139+
)
121140
})
122141
})

website/src/content/docs/changelog.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ description: The release notes for HTMLHint, see what's changed with each new ve
55

66
import { Badge } from '@astrojs/starlight/components'
77

8+
## 1.6.3 _(2025-06-18)_
9+
10+
- <Badge text="Fix" size="small" variant="danger" /> Improve [`attr-value-no-duplication`](/rules/attr-value-no-duplication/) logic
11+
812
## 1.6.2 _(2025-06-18)_
913

1014
- <Badge text="Fix" size="small" variant="danger" /> Improve [`attr-value-no-duplication`](/rules/attr-value-no-duplication/) logic

website/src/content/docs/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ hero:
1414
variant: minimal
1515
banner:
1616
content: |
17-
v1.6.2 is now available! Check the <a href="/changelog/">changelog</a> to see what's new.
17+
v1.6.3 is now available! Check the <a href="/changelog/">changelog</a> to see what's new.
1818
tableOfContents: false
1919
lastUpdated: false
2020
editUrl: false

website/src/content/docs/rules/attr-value-no-duplication.mdx

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,66 +8,66 @@ sidebar:
88

99
import { Badge } from '@astrojs/starlight/components';
1010

11-
Attribute values should not contain duplicates.
11+
Class attributes should not contain duplicate values. Other attributes can be checked via configuration.
1212

1313
Level: <Badge text="Error" variant="danger" />
1414

1515
## Config value
1616

17-
- `true`: enable rule
17+
- `true`: enable rule with default attributes (only class)
18+
- `['attr1', 'attr2', ...]`: specify custom list of attributes to check
1819
- `false`: disable rule
1920

20-
### The following patterns are **not** considered rule violations
21+
## Default attributes checked
2122

22-
```html
23-
<div class="container fluid small">Content</div>
24-
```
23+
By default, this rule only checks the `class` attribute for duplicate values:
2524

26-
```html
27-
<span data-toggle="modal">Content</span>
28-
```
25+
- `class` - CSS class names should not be repeated
26+
27+
Other attributes can be checked by providing a custom configuration.
28+
29+
### The following patterns are **not** considered rule violations
2930

3031
```html
31-
<div class="btn btn-primary btn-large">Button</div>
32+
<div class="container fluid small">Content</div>
3233
```
3334

3435
```html
35-
<source type="" sizes="(min-width: 1rem) 1vw, (min-width: 2rem) 2vw" srcset="">
36+
<!-- data-attributes not checked by default -->
37+
<input data-attribute="apple banana apple">
3638
```
3739

38-
## Excluded attributes
39-
40-
The following attributes are excluded from this rule and will not trigger errors even if they contain duplicate values:
41-
42-
- `content` - Common for meta tags that may contain duplicate keywords
43-
- `d` - Used in SVG path data which may contain repetitive pattern data
44-
- `media` - Used for media queries which have special parsing rules
45-
- `on*` - All event handlers (onclick, onkeydown, etc.) that may contain legitimate duplicate JavaScript operations
46-
- `sizes` - Used in responsive images with complex viewport conditions
47-
- `src` - URLs and data URIs that might legitimately contain repeated patterns
48-
- `style` - Inline CSS which has its own syntax and may contain duplicate property names
49-
5040
### The following patterns are considered rule violations:
5141

5242
```html
5343
<div class="d-none small d-none">Content</div>
5444
```
5545

56-
```html
57-
<span data-attributes="dark light dark">Content</span>
58-
```
59-
60-
```html
61-
<div class="btn primary btn">Button</div>
62-
```
63-
6446
## Why does this rule exist?
6547

66-
Having duplicate values in attributes like `class` or custom data attributes can:
48+
Having duplicate values in class attributes can:
6749

6850
- Make the markup unnecessarily verbose
6951
- Cause confusion during development
7052
- Lead to inefficient CSS specificity calculations
7153
- Indicate potential copy-paste errors or oversight
7254

7355
This rule helps maintain clean, efficient markup by catching these duplicates early.
56+
57+
## Custom configuration
58+
59+
You can customize which attributes to check by providing an array:
60+
61+
```json
62+
{
63+
"attr-value-no-duplication": ["class", "id", "name", "role"]
64+
}
65+
```
66+
67+
```json
68+
{
69+
"attr-value-no-duplication": ["data-test", "aria-label", "custom-attr"]
70+
}
71+
```
72+
73+
This allows you to focus on attributes specific to your project needs.

website/src/content/docs/rules/tagname-lowercase.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ Level: <Badge text="Error" variant="danger" />
1414

1515
- `true`: enable rule
1616
- `false`: disable rule
17-
18-
3. ['clipPath', 'test']: Ignore some tagname name
17+
- `['clipPath', 'data-Test']`: Ignore some tagname name
1918

2019
### The following patterns are **not** considered rule violations
2120

0 commit comments

Comments
 (0)