From cc66c0f810f3a39e21c5555fc3bf62452765e661 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Tue, 10 Mar 2026 18:27:13 -0400
Subject: [PATCH] Extract rule: template-no-duplicate-attributes
---
README.md | 1 +
.../rules/template-no-duplicate-attributes.md | 59 ++++++
lib/rules/template-no-duplicate-attributes.js | 85 +++++++++
.../rules/template-no-duplicate-attributes.js | 179 ++++++++++++++++++
4 files changed, 324 insertions(+)
create mode 100644 docs/rules/template-no-duplicate-attributes.md
create mode 100644 lib/rules/template-no-duplicate-attributes.js
create mode 100644 tests/lib/rules/template-no-duplicate-attributes.js
diff --git a/README.md b/README.md
index 4709a8b15c..908553fc75 100644
--- a/README.md
+++ b/README.md
@@ -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) | | | |
diff --git a/docs/rules/template-no-duplicate-attributes.md b/docs/rules/template-no-duplicate-attributes.md
new file mode 100644
index 0000000000..2fec029aa6
--- /dev/null
+++ b/docs/rules/template-no-duplicate-attributes.md
@@ -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).
+
+
+
+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
+
+
+
+```
+
+```gjs
+
+
+
+```
+
+```gjs
+
+ {{helper foo="bar" foo="baz"}}
+
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+
+
+
+```
+
+```gjs
+
+
+
+```
+
+```gjs
+
+ {{helper foo="bar" baz="qux"}}
+
+```
+
+## 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)
diff --git a/lib/rules/template-no-duplicate-attributes.js b/lib/rules/template-no-duplicate-attributes.js
new file mode 100644
index 0000000000..750b3d9a86
--- /dev/null
+++ b/lib/rules/template-no-duplicate-attributes.js
@@ -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');
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-no-duplicate-attributes.js b/tests/lib/rules/template-no-duplicate-attributes.js
new file mode 100644
index 0000000000..e1ea5d6de4
--- /dev/null
+++ b/tests/lib/rules/template-no-duplicate-attributes.js
@@ -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: [
+ `
+
+ `,
+ `
+
+ `,
+ `
+ {{helper arg1="a" arg2="b"}}
+ `,
+ `
+ {{#each items as |item|}}
+ {{item}}
+ {{/each}}
+ `,
+
+ '{{my-component firstName=firstName lastName=lastName}}',
+ ' {{fullName}}',
+ '{{btnLabel}}',
+ '{{employee-profile employee=(hash firstName=firstName lastName=lastName age=age)}}',
+ '{{employee-profile employee=(hash fullName=(hash firstName=firstName lastName=lastName) age=age)}}',
+
+ // Block form with params (no duplicates)
+ '{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{fullName}}{{/my-component}}',
+ ],
+
+ invalid: [
+ {
+ code: `
+
+ `,
+ output: `
+
+ `,
+ errors: [
+ {
+ message: "Duplicate attribute 'class' found in the Element.",
+ type: 'GlimmerAttrNode',
+ },
+ ],
+ },
+ {
+ code: `
+
+ `,
+ output: `
+
+ `,
+ errors: [
+ {
+ message: "Duplicate attribute 'type' found in the Element.",
+ type: 'GlimmerAttrNode',
+ },
+ ],
+ },
+ {
+ code: `
+ {{helper foo="bar" foo="baz"}}
+ `,
+ output: `
+ {{helper foo="bar"}}
+ `,
+ errors: [
+ {
+ message: "Duplicate attribute 'foo' found in the MustacheStatement.",
+ type: 'GlimmerHashPair',
+ },
+ ],
+ },
+ {
+ code: `
+ {{#if condition key="a" key="b"}}
+ content
+ {{/if}}
+ `,
+ output: `
+ {{#if condition key="a"}}
+ content
+ {{/if}}
+ `,
+ errors: [
+ {
+ message: "Duplicate attribute 'key' found in the BlockStatement.",
+ type: 'GlimmerHashPair',
+ },
+ ],
+ },
+
+ {
+ 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|}}{{/my-component}}',
+ output:
+ '{{#my-component firstName=firstName lastName=lastName as |fullName|}}{{/my-component}}',
+ errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }],
+ },
+ {
+ code: '{{btnLabel}}',
+ output: '{{btnLabel}}',
+ 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' } }],
+ },
+ ],
+});
+
+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}}',
+ '{{btnLabel}}',
+ '{{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: '{{btnLabel}}',
+ output: '{{btnLabel}}',
+ 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' } }],
+ },
+ ],
+});