Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
| [template-no-duplicate-attributes](docs/rules/template-no-duplicate-attributes.md) | disallow duplicate attribute names in templates | | 🔧 | |
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
Expand Down
59 changes: 59 additions & 0 deletions docs/rules/template-no-duplicate-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# ember/template-no-duplicate-attributes

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

Disallows duplicate attribute names in templates.

Duplicate attributes on the same element can lead to unexpected behavior and are often a mistake.

## Rule Details

This rule disallows duplicate attributes on HTML elements, components, and helpers.

## Examples

Examples of **incorrect** code for this rule:

```gjs
<template>
<div class="foo" class="bar"></div>
</template>
```

```gjs
<template>
<input type="text" disabled type="email" />
</template>
```

```gjs
<template>
{{helper foo="bar" foo="baz"}}
</template>
```

Examples of **correct** code for this rule:

```gjs
<template>
<div class="foo bar"></div>
</template>
```

```gjs
<template>
<input type="email" disabled />
</template>
```

```gjs
<template>
{{helper foo="bar" baz="qux"}}
</template>
```

## References

- [eslint-plugin-ember template-no-duplicate-attributes](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-duplicate-attributes.md)
85 changes: 85 additions & 0 deletions lib/rules/template-no-duplicate-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow duplicate attribute names in templates',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-attributes.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
duplicateElement: "Duplicate attribute '{{name}}' found in the Element.",
duplicateBlock: "Duplicate attribute '{{name}}' found in the BlockStatement.",
duplicateMustache: "Duplicate attribute '{{name}}' found in the MustacheStatement.",
duplicateSubExpr: "Duplicate attribute '{{name}}' found in the SubExpression.",
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-duplicate-attributes.js',
docs: 'docs/rule/no-duplicate-attributes.md',
tests: 'test/unit/rules/no-duplicate-attributes-test.js',
},
},

create(context) {
function checkForDuplicates(node, attributes, identifier, messageId) {
if (!attributes || attributes.length < 2) {
return;
}

const seen = new Map();

for (const attr of attributes) {
const key = attr[identifier];
if (seen.has(key)) {
context.report({
node: attr,
messageId,
data: { name: key },
fix(fixer) {
// Remove the duplicate attribute including preceding whitespace
const sourceCode = context.sourceCode;
const text = sourceCode.getText();
const attrStart = attr.range[0];
const attrEnd = attr.range[1];

// Look for whitespace before the attribute
let removeStart = attrStart;
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
removeStart--;
}

return fixer.removeRange([removeStart, attrEnd]);
},
});
} else {
seen.set(key, attr);
}
}
}

return {
GlimmerElementNode(node) {
checkForDuplicates(node, node.attributes, 'name', 'duplicateElement');
},

GlimmerBlockStatement(node) {
const attributes = node.hash?.pairs || [];
checkForDuplicates(node, attributes, 'key', 'duplicateBlock');
},

GlimmerMustacheStatement(node) {
const attributes = node.hash?.pairs || [];
checkForDuplicates(node, attributes, 'key', 'duplicateMustache');
},

GlimmerSubExpression(node) {
const attributes = node.hash?.pairs || [];
checkForDuplicates(node, attributes, 'key', 'duplicateSubExpr');
},
};
},
};
179 changes: 179 additions & 0 deletions tests/lib/rules/template-no-duplicate-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require('../../../lib/rules/template-no-duplicate-attributes');
const RuleTester = require('eslint').RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('template-no-duplicate-attributes', rule, {
valid: [
`<template>
<div class="foo" id="bar"></div>
</template>`,
`<template>
<button type="button" disabled></button>
</template>`,
`<template>
{{helper arg1="a" arg2="b"}}
</template>`,
`<template>
{{#each items as |item|}}
{{item}}
{{/each}}
</template>`,

'<template>{{my-component firstName=firstName lastName=lastName}}</template>',
'<template> {{fullName}}</template>',
'<template><a class="btn">{{btnLabel}}</a></template>',
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',

// Block form with params (no duplicates)
'<template>{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{fullName}}{{/my-component}}</template>',
],

invalid: [
{
code: `<template>
<div class="foo" class="bar"></div>
</template>`,
output: `<template>
<div class="foo"></div>
</template>`,
errors: [
{
message: "Duplicate attribute 'class' found in the Element.",
type: 'GlimmerAttrNode',
},
],
},
{
code: `<template>
<input type="text" disabled type="email" />
</template>`,
output: `<template>
<input type="text" disabled />
</template>`,
errors: [
{
message: "Duplicate attribute 'type' found in the Element.",
type: 'GlimmerAttrNode',
},
],
},
{
code: `<template>
{{helper foo="bar" foo="baz"}}
</template>`,
output: `<template>
{{helper foo="bar"}}
</template>`,
errors: [
{
message: "Duplicate attribute 'foo' found in the MustacheStatement.",
type: 'GlimmerHashPair',
},
],
},
{
code: `<template>
{{#if condition key="a" key="b"}}
content
{{/if}}
</template>`,
output: `<template>
{{#if condition key="a"}}
content
{{/if}}
</template>`,
errors: [
{
message: "Duplicate attribute 'key' found in the BlockStatement.",
type: 'GlimmerHashPair',
},
],
},

{
code: '<template>{{my-component firstName=firstName lastName=lastName firstName=firstName}}</template>',
output: '<template>{{my-component firstName=firstName lastName=lastName}}</template>',
errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }],
},
{
code: '<template>{{#my-component firstName=firstName lastName=lastName firstName=firstName as |fullName|}}{{/my-component}}</template>',
output:
'<template>{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{/my-component}}</template>',
errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
},
{
code: '<template><a class="btn" class="btn">{{btnLabel}}</a></template>',
output: '<template><a class="btn">{{btnLabel}}</a></template>',
errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }],
},
{
code: '<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}</template>',
output:
'<template>{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}</template>',
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
},
{
code: '<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}</template>',
output:
'<template>{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}</template>',
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('template-no-duplicate-attributes (hbs)', rule, {
valid: [
'{{my-component firstName=firstName lastName=lastName}}',
'{{#my-component firstName=firstName lastName=lastName as |fullName|}} {{fullName}}{{/my-component}}',
'<a class="btn">{{btnLabel}}</a>',
'{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
'{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}',
],
invalid: [
{
code: '{{my-component firstName=firstName lastName=lastName firstName=firstName}}',
output: '{{my-component firstName=firstName lastName=lastName}}',
errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }],
},
{
code: '{{#my-component firstName=firstName lastName=lastName firstName=firstName as |fullName|}} {{fullName}}{{/my-component}}',
output:
'{{#my-component firstName=firstName lastName=lastName as |fullName|}} {{fullName}}{{/my-component}}',
errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
},
{
code: '<a class="btn" class="btn">{{btnLabel}}</a>',
output: '<a class="btn">{{btnLabel}}</a>',
errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }],
},
{
code: '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age firstName=firstName)}}',
output: '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
},
{
code: '{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName firstName=firstName) age=age)}}',
output:
'{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}',
errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }],
},
],
});
Loading