Skip to content

Commit e7569a0

Browse files
Merge pull request #2419 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-duplicate-attributes
Extract rule: template-no-duplicate-attributes
2 parents c034e44 + cc66c0f commit e7569a0

4 files changed

Lines changed: 324 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ rules in templates can be disabled with eslint directives with mustache or html
216216
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
217217
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
218218
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
219+
| [template-no-duplicate-attributes](docs/rules/template-no-duplicate-attributes.md) | disallow duplicate attribute names in templates | | 🔧 | |
219220
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
220221
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
221222
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ember/template-no-duplicate-attributes
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Disallows duplicate attribute names in templates.
8+
9+
Duplicate attributes on the same element can lead to unexpected behavior and are often a mistake.
10+
11+
## Rule Details
12+
13+
This rule disallows duplicate attributes on HTML elements, components, and helpers.
14+
15+
## Examples
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
```gjs
20+
<template>
21+
<div class="foo" class="bar"></div>
22+
</template>
23+
```
24+
25+
```gjs
26+
<template>
27+
<input type="text" disabled type="email" />
28+
</template>
29+
```
30+
31+
```gjs
32+
<template>
33+
{{helper foo="bar" foo="baz"}}
34+
</template>
35+
```
36+
37+
Examples of **correct** code for this rule:
38+
39+
```gjs
40+
<template>
41+
<div class="foo bar"></div>
42+
</template>
43+
```
44+
45+
```gjs
46+
<template>
47+
<input type="email" disabled />
48+
</template>
49+
```
50+
51+
```gjs
52+
<template>
53+
{{helper foo="bar" baz="qux"}}
54+
</template>
55+
```
56+
57+
## References
58+
59+
- [eslint-plugin-ember template-no-duplicate-attributes](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-duplicate-attributes.md)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow duplicate attribute names in templates',
7+
category: 'Best Practices',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-attributes.md',
9+
templateMode: 'both',
10+
},
11+
fixable: 'code',
12+
schema: [],
13+
messages: {
14+
duplicateElement: "Duplicate attribute '{{name}}' found in the Element.",
15+
duplicateBlock: "Duplicate attribute '{{name}}' found in the BlockStatement.",
16+
duplicateMustache: "Duplicate attribute '{{name}}' found in the MustacheStatement.",
17+
duplicateSubExpr: "Duplicate attribute '{{name}}' found in the SubExpression.",
18+
},
19+
originallyFrom: {
20+
name: 'ember-template-lint',
21+
rule: 'lib/rules/no-duplicate-attributes.js',
22+
docs: 'docs/rule/no-duplicate-attributes.md',
23+
tests: 'test/unit/rules/no-duplicate-attributes-test.js',
24+
},
25+
},
26+
27+
create(context) {
28+
function checkForDuplicates(node, attributes, identifier, messageId) {
29+
if (!attributes || attributes.length < 2) {
30+
return;
31+
}
32+
33+
const seen = new Map();
34+
35+
for (const attr of attributes) {
36+
const key = attr[identifier];
37+
if (seen.has(key)) {
38+
context.report({
39+
node: attr,
40+
messageId,
41+
data: { name: key },
42+
fix(fixer) {
43+
// Remove the duplicate attribute including preceding whitespace
44+
const sourceCode = context.sourceCode;
45+
const text = sourceCode.getText();
46+
const attrStart = attr.range[0];
47+
const attrEnd = attr.range[1];
48+
49+
// Look for whitespace before the attribute
50+
let removeStart = attrStart;
51+
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
52+
removeStart--;
53+
}
54+
55+
return fixer.removeRange([removeStart, attrEnd]);
56+
},
57+
});
58+
} else {
59+
seen.set(key, attr);
60+
}
61+
}
62+
}
63+
64+
return {
65+
GlimmerElementNode(node) {
66+
checkForDuplicates(node, node.attributes, 'name', 'duplicateElement');
67+
},
68+
69+
GlimmerBlockStatement(node) {
70+
const attributes = node.hash?.pairs || [];
71+
checkForDuplicates(node, attributes, 'key', 'duplicateBlock');
72+
},
73+
74+
GlimmerMustacheStatement(node) {
75+
const attributes = node.hash?.pairs || [];
76+
checkForDuplicates(node, attributes, 'key', 'duplicateMustache');
77+
},
78+
79+
GlimmerSubExpression(node) {
80+
const attributes = node.hash?.pairs || [];
81+
checkForDuplicates(node, attributes, 'key', 'duplicateSubExpr');
82+
},
83+
};
84+
},
85+
};
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/template-no-duplicate-attributes');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
//------------------------------------------------------------------------------
9+
// Tests
10+
//------------------------------------------------------------------------------
11+
12+
const ruleTester = new RuleTester({
13+
parser: require.resolve('ember-eslint-parser'),
14+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
15+
});
16+
17+
ruleTester.run('template-no-duplicate-attributes', rule, {
18+
valid: [
19+
`<template>
20+
<div class="foo" id="bar"></div>
21+
</template>`,
22+
`<template>
23+
<button type="button" disabled></button>
24+
</template>`,
25+
`<template>
26+
{{helper arg1="a" arg2="b"}}
27+
</template>`,
28+
`<template>
29+
{{#each items as |item|}}
30+
{{item}}
31+
{{/each}}
32+
</template>`,
33+
34+
'<template>{{my-component firstName=firstName lastName=lastName}}</template>',
35+
'<template> {{fullName}}</template>',
36+
'<template><a class="btn">{{btnLabel}}</a></template>',
37+
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
38+
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',
39+
40+
// Block form with params (no duplicates)
41+
'<template>{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{fullName}}{{/my-component}}</template>',
42+
],
43+
44+
invalid: [
45+
{
46+
code: `<template>
47+
<div class="foo" class="bar"></div>
48+
</template>`,
49+
output: `<template>
50+
<div class="foo"></div>
51+
</template>`,
52+
errors: [
53+
{
54+
message: "Duplicate attribute 'class' found in the Element.",
55+
type: 'GlimmerAttrNode',
56+
},
57+
],
58+
},
59+
{
60+
code: `<template>
61+
<input type="text" disabled type="email" />
62+
</template>`,
63+
output: `<template>
64+
<input type="text" disabled />
65+
</template>`,
66+
errors: [
67+
{
68+
message: "Duplicate attribute 'type' found in the Element.",
69+
type: 'GlimmerAttrNode',
70+
},
71+
],
72+
},
73+
{
74+
code: `<template>
75+
{{helper foo="bar" foo="baz"}}
76+
</template>`,
77+
output: `<template>
78+
{{helper foo="bar"}}
79+
</template>`,
80+
errors: [
81+
{
82+
message: "Duplicate attribute 'foo' found in the MustacheStatement.",
83+
type: 'GlimmerHashPair',
84+
},
85+
],
86+
},
87+
{
88+
code: `<template>
89+
{{#if condition key="a" key="b"}}
90+
content
91+
{{/if}}
92+
</template>`,
93+
output: `<template>
94+
{{#if condition key="a"}}
95+
content
96+
{{/if}}
97+
</template>`,
98+
errors: [
99+
{
100+
message: "Duplicate attribute 'key' found in the BlockStatement.",
101+
type: 'GlimmerHashPair',
102+
},
103+
],
104+
},
105+
106+
{
107+
code: '<template>{{my-component firstName=firstName lastName=lastName firstName=firstName}}</template>',
108+
output: '<template>{{my-component firstName=firstName lastName=lastName}}</template>',
109+
errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }],
110+
},
111+
{
112+
code: '<template>{{#my-component firstName=firstName lastName=lastName firstName=firstName as |fullName|}}{{/my-component}}</template>',
113+
output:
114+
'<template>{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{/my-component}}</template>',
115+
errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
116+
},
117+
{
118+
code: '<template><a class="btn" class="btn">{{btnLabel}}</a></template>',
119+
output: '<template><a class="btn">{{btnLabel}}</a></template>',
120+
errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }],
121+
},
122+
{
123+
code: '<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}</template>',
124+
output:
125+
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
126+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
127+
},
128+
{
129+
code: '<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}</template>',
130+
output:
131+
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',
132+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
133+
},
134+
],
135+
});
136+
137+
const hbsRuleTester = new RuleTester({
138+
parser: require.resolve('ember-eslint-parser/hbs'),
139+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
140+
});
141+
142+
hbsRuleTester.run('template-no-duplicate-attributes (hbs)', rule, {
143+
valid: [
144+
'{{my-component firstName=firstName lastName=lastName}}',
145+
'{{#my-component firstName=firstName lastName=lastName as |fullName|}} {{fullName}}{{/my-component}}',
146+
'<a class="btn">{{btnLabel}}</a>',
147+
'{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
148+
'{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}',
149+
],
150+
invalid: [
151+
{
152+
code: '{{my-component firstName=firstName lastName=lastName firstName=firstName}}',
153+
output: '{{my-component firstName=firstName lastName=lastName}}',
154+
errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }],
155+
},
156+
{
157+
code: '{{#my-component firstName=firstName lastName=lastName firstName=firstName as |fullName|}} {{fullName}}{{/my-component}}',
158+
output:
159+
'{{#my-component firstName=firstName lastName=lastName as |fullName|}} {{fullName}}{{/my-component}}',
160+
errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
161+
},
162+
{
163+
code: '<a class="btn" class="btn">{{btnLabel}}</a>',
164+
output: '<a class="btn">{{btnLabel}}</a>',
165+
errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }],
166+
},
167+
{
168+
code: '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}',
169+
output: '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
170+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
171+
},
172+
{
173+
code: '{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}',
174+
output:
175+
'{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}',
176+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
177+
},
178+
],
179+
});

0 commit comments

Comments
 (0)