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 + +``` + +Examples of **correct** code for this rule: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## 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: [ + ``, + ``, + ``, + ``, + + '', + '', + '', + '', + '', + + // Block form with params (no duplicates) + '', + ], + + 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: ``, + output: ``, + errors: [ + { + message: "Duplicate attribute 'foo' found in the MustacheStatement.", + type: 'GlimmerHashPair', + }, + ], + }, + { + code: ``, + output: ``, + errors: [ + { + message: "Duplicate attribute 'key' found in the BlockStatement.", + type: 'GlimmerHashPair', + }, + ], + }, + + { + code: '', + output: '', + errors: [{ messageId: 'duplicateMustache', data: { name: 'firstName' } }], + }, + { + code: '', + output: + '', + errors: [{ messageId: 'duplicateBlock', data: { name: 'firstName' } }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'duplicateElement', data: { name: 'class' } }], + }, + { + code: '', + output: + '', + errors: [{ messageId: 'duplicateSubExpr', data: { name: 'firstName' } }], + }, + { + code: '', + output: + '', + 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' } }], + }, + ], +});