Skip to content

Commit e49e216

Browse files
committed
Extract rule: template-no-duplicate-attributes
1 parent 0149ef1 commit e49e216

4 files changed

Lines changed: 274 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ rules in templates can be disabled with eslint directives with mustache or html
198198
| [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | |
199199
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
200200
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
201+
| [template-no-duplicate-attributes](docs/rules/template-no-duplicate-attributes.md) | disallow duplicate attribute names in templates | | 🔧 | |
201202
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
202203
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
203204

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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
strictGjs: true,
9+
strictGts: true,
10+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-attributes.md',
11+
},
12+
fixable: 'code',
13+
schema: [],
14+
messages: {
15+
duplicateElement: "Duplicate attribute '{{name}}' found in the Element.",
16+
duplicateBlock: "Duplicate attribute '{{name}}' found in the BlockStatement.",
17+
duplicateMustache: "Duplicate attribute '{{name}}' found in the MustacheStatement.",
18+
duplicateSubExpr: "Duplicate attribute '{{name}}' found in the SubExpression.",
19+
},
20+
},
21+
22+
create(context) {
23+
function checkForDuplicates(node, attributes, identifier, messageId) {
24+
if (!attributes || attributes.length < 2) {
25+
return;
26+
}
27+
28+
const seen = new Map();
29+
30+
for (const attr of attributes) {
31+
const key = attr[identifier];
32+
if (seen.has(key)) {
33+
context.report({
34+
node: attr,
35+
messageId,
36+
data: { name: key },
37+
fix(fixer) {
38+
// Remove the duplicate attribute including preceding whitespace
39+
const sourceCode = context.sourceCode;
40+
const text = sourceCode.getText();
41+
const attrStart = attr.range[0];
42+
const attrEnd = attr.range[1];
43+
44+
// Look for whitespace before the attribute
45+
let removeStart = attrStart;
46+
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
47+
removeStart--;
48+
}
49+
50+
return fixer.removeRange([removeStart, attrEnd]);
51+
},
52+
});
53+
} else {
54+
seen.set(key, attr);
55+
}
56+
}
57+
}
58+
59+
return {
60+
GlimmerElementNode(node) {
61+
checkForDuplicates(node, node.attributes, 'name', 'duplicateElement');
62+
},
63+
64+
GlimmerBlockStatement(node) {
65+
const attributes = node.hash?.pairs || [];
66+
checkForDuplicates(node, attributes, 'key', 'duplicateBlock');
67+
},
68+
69+
GlimmerMustacheStatement(node) {
70+
const attributes = node.hash?.pairs || [];
71+
checkForDuplicates(node, attributes, 'key', 'duplicateMustache');
72+
},
73+
74+
GlimmerSubExpression(node) {
75+
const attributes = node.hash?.pairs || [];
76+
checkForDuplicates(node, attributes, 'key', 'duplicateSubExpr');
77+
},
78+
};
79+
},
80+
};
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
// Test cases ported from eslint-plugin-ember
35+
'<template>{{my-component firstName=firstName lastName=lastName}}</template>',
36+
'<template> {{fullName}}</template>',
37+
'<template><a class="btn">{{btnLabel}}</a></template>',
38+
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
39+
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',
40+
],
41+
42+
invalid: [
43+
{
44+
code: `<template>
45+
<div class="foo" class="bar"></div>
46+
</template>`,
47+
output: `<template>
48+
<div class="foo"></div>
49+
</template>`,
50+
errors: [
51+
{
52+
message: "Duplicate attribute 'class' found in the Element.",
53+
type: 'GlimmerAttrNode',
54+
},
55+
],
56+
},
57+
{
58+
code: `<template>
59+
<input type="text" disabled type="email" />
60+
</template>`,
61+
output: `<template>
62+
<input type="text" disabled />
63+
</template>`,
64+
errors: [
65+
{
66+
message: "Duplicate attribute 'type' found in the Element.",
67+
type: 'GlimmerAttrNode',
68+
},
69+
],
70+
},
71+
{
72+
code: `<template>
73+
{{helper foo="bar" foo="baz"}}
74+
</template>`,
75+
output: `<template>
76+
{{helper foo="bar"}}
77+
</template>`,
78+
errors: [
79+
{
80+
message: "Duplicate attribute 'foo' found in the MustacheStatement.",
81+
type: 'GlimmerHashPair',
82+
},
83+
],
84+
},
85+
{
86+
code: `<template>
87+
{{#if condition key="a" key="b"}}
88+
content
89+
{{/if}}
90+
</template>`,
91+
output: `<template>
92+
{{#if condition key="a"}}
93+
content
94+
{{/if}}
95+
</template>`,
96+
errors: [
97+
{
98+
message: "Duplicate attribute 'key' found in the BlockStatement.",
99+
type: 'GlimmerHashPair',
100+
},
101+
],
102+
},
103+
104+
// Test cases ported from eslint-plugin-ember
105+
{
106+
code: '<template>{{my-component firstName=firstName lastName=lastName firstName=firstName}}</template>',
107+
output: '<template>{{my-component firstName=firstName lastName=lastName}}</template>',
108+
errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }],
109+
},
110+
{
111+
code: '<template>{{#my-component firstName=firstName lastName=lastName firstName=firstName as |fullName|}}{{/my-component}}</template>',
112+
output:
113+
'<template>{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{/my-component}}</template>',
114+
errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
115+
},
116+
{
117+
code: '<template><a class="btn" class="btn">{{btnLabel}}</a></template>',
118+
output: '<template><a class="btn">{{btnLabel}}</a></template>',
119+
errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }],
120+
},
121+
{
122+
code: '<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}</template>',
123+
output:
124+
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
125+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
126+
},
127+
{
128+
code: '<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}</template>',
129+
output:
130+
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',
131+
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
132+
},
133+
],
134+
});

0 commit comments

Comments
 (0)