Skip to content

Commit 3b4ba96

Browse files
committed
Extract rule: template-no-duplicate-id
1 parent 3f6a7c8 commit 3b4ba96

4 files changed

Lines changed: 888 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html
202202
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
203203
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
204204
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
205+
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
205206
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
206207
| [template-no-inline-event-handlers](docs/rules/template-no-inline-event-handlers.md) | disallow DOM event handler attributes | | | |
207208
| [template-no-inline-styles](docs/rules/template-no-inline-styles.md) | disallow inline styles | | | |
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ember/template-no-duplicate-id
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Valid HTML requires that `id` attribute values are unique.
6+
7+
This rule does a basic check to ensure that `id` attribute values are not the same.
8+
9+
## Examples
10+
11+
This rule **forbids** the following:
12+
13+
```gjs
14+
<template><div id='id-00'></div><div id='id-00'></div></template>
15+
```
16+
17+
This rule **allows** the following:
18+
19+
```gjs
20+
<template><div id={{this.divId}}></div></template>
21+
```
22+
23+
```gjs
24+
<template><div id='concat-{{this.divId}}'></div></template>
25+
```
26+
27+
```gjs
28+
<template>
29+
<MyComponent as |inputProperties|>
30+
<Input id={{inputProperties.id}} />
31+
<div id={{inputProperties.abc}} />
32+
</MyComponent>
33+
34+
<MyComponent as |inputProperties|>
35+
<Input id={{inputProperties.id}} />
36+
</MyComponent>
37+
</template>
38+
```
39+
40+
## Migration
41+
42+
For best results, it is recommended to generate `id` attribute values when they are needed, to ensure that they are not duplicates.
43+
44+
## References
45+
46+
- <https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#the-id-attribute>
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
const IGNORE_IDS = new Set(['{{unique-id}}', '{{(unique-id)}}']);
2+
3+
function isControlFlowHelper(node) {
4+
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
5+
return ['if', 'unless', 'each', 'each-in', 'let', 'with'].includes(node.path.original);
6+
}
7+
return false;
8+
}
9+
10+
function isIfUnless(node) {
11+
if (node.type === 'GlimmerBlockStatement' && node.path?.type === 'GlimmerPathExpression') {
12+
return ['if', 'unless'].includes(node.path.original);
13+
}
14+
return false;
15+
}
16+
17+
/** @type {import('eslint').Rule.RuleModule} */
18+
module.exports = {
19+
meta: {
20+
type: 'problem',
21+
docs: {
22+
description: 'disallow duplicate id attributes',
23+
category: 'Best Practices',
24+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-id.md',
25+
templateMode: 'both',
26+
},
27+
schema: [],
28+
messages: { duplicate: 'ID attribute values must be unique' },
29+
originallyFrom: {
30+
name: 'ember-template-lint',
31+
rule: 'lib/rules/no-duplicate-id.js',
32+
docs: 'docs/rule/no-duplicate-id.md',
33+
tests: 'test/unit/rules/no-duplicate-id-test.js',
34+
},
35+
},
36+
create(context) {
37+
const sourceCode = context.getSourceCode();
38+
// Stack-based conditional scoping to handle if/else branches
39+
let seenIdStack = [];
40+
let conditionalStack = [];
41+
let conditionalReportedDuplicates = [];
42+
43+
function enterTemplate() {
44+
seenIdStack = [new Set()];
45+
conditionalStack = [];
46+
conditionalReportedDuplicates = [];
47+
}
48+
49+
function isDuplicateId(id) {
50+
for (const seenIds of seenIdStack) {
51+
if (seenIds.has(id)) {
52+
return true;
53+
}
54+
}
55+
return false;
56+
}
57+
58+
function addId(id) {
59+
seenIdStack.at(-1).add(id);
60+
if (conditionalStack.length > 0) {
61+
conditionalStack.at(-1).add(id);
62+
}
63+
}
64+
65+
function enterConditional() {
66+
conditionalStack.push(new Set());
67+
conditionalReportedDuplicates.push(new Set());
68+
}
69+
70+
function exitConditional() {
71+
const idsInConditional = conditionalStack.pop();
72+
conditionalReportedDuplicates.pop();
73+
if (conditionalStack.length > 0) {
74+
for (const id of idsInConditional) {
75+
conditionalStack.at(-1).add(id);
76+
}
77+
} else {
78+
seenIdStack.push(idsInConditional);
79+
}
80+
}
81+
82+
function enterConditionalBranch() {
83+
seenIdStack.push(new Set());
84+
}
85+
86+
function exitConditionalBranch() {
87+
seenIdStack.pop();
88+
}
89+
90+
function resolveIdValue(valueNode, node) {
91+
if (!valueNode) {
92+
return null;
93+
}
94+
95+
switch (valueNode.type) {
96+
case 'GlimmerTextNode': {
97+
return valueNode.chars || null;
98+
}
99+
case 'GlimmerStringLiteral': {
100+
return valueNode.value || null;
101+
}
102+
case 'GlimmerMustacheStatement': {
103+
// Try to resolve {{...}} - if it's a string literal path, use value
104+
if (valueNode.path?.type === 'GlimmerStringLiteral') {
105+
return valueNode.path.value;
106+
}
107+
// For path expressions, use the source text as a best-effort unique key
108+
return sourceCode.getText(valueNode);
109+
}
110+
case 'GlimmerConcatStatement': {
111+
// Concatenate resolved parts
112+
if (valueNode.parts) {
113+
return valueNode.parts
114+
.map((part) => {
115+
if (part.type === 'GlimmerTextNode') {
116+
return part.chars;
117+
}
118+
if (
119+
part.type === 'GlimmerMustacheStatement' &&
120+
part.path?.type === 'GlimmerStringLiteral'
121+
) {
122+
return part.path.value;
123+
}
124+
return sourceCode.getText(part);
125+
})
126+
.join('');
127+
}
128+
return sourceCode.getText(valueNode);
129+
}
130+
default: {
131+
return sourceCode.getText(valueNode);
132+
}
133+
}
134+
}
135+
136+
function logIfDuplicate(reportNode, id) {
137+
if (!id) {
138+
return;
139+
}
140+
if (IGNORE_IDS.has(id)) {
141+
return;
142+
}
143+
if (isDuplicateId(id)) {
144+
// If inside a conditional, only report each duplicate ID once across branches
145+
if (conditionalReportedDuplicates.length > 0) {
146+
const reported = conditionalReportedDuplicates.at(-1);
147+
if (reported.has(id)) {
148+
return;
149+
}
150+
reported.add(id);
151+
}
152+
context.report({ node: reportNode, messageId: 'duplicate' });
153+
} else {
154+
addId(id);
155+
}
156+
}
157+
158+
return {
159+
GlimmerTemplate() {
160+
enterTemplate();
161+
},
162+
'GlimmerTemplate:exit'() {
163+
seenIdStack = [new Set()];
164+
conditionalStack = [];
165+
conditionalReportedDuplicates = [];
166+
},
167+
168+
GlimmerElementNode(node) {
169+
// If element has block params, enter a scope
170+
if (node.blockParams && node.blockParams.length > 0) {
171+
seenIdStack.push(new Set());
172+
}
173+
174+
// Check id, @id, @elementId attributes
175+
const idAttrNames = new Set(['id', '@id', '@elementId']);
176+
for (const attr of node.attributes || []) {
177+
if (idAttrNames.has(attr.name)) {
178+
const id = resolveIdValue(attr.value, node);
179+
logIfDuplicate(attr, id);
180+
}
181+
}
182+
},
183+
184+
'GlimmerElementNode:exit'(node) {
185+
if (node.blockParams && node.blockParams.length > 0) {
186+
seenIdStack.pop();
187+
}
188+
},
189+
190+
// Handle hash pairs in mustache/block statements (e.g., {{input elementId="foo"}})
191+
GlimmerMustacheStatement(node) {
192+
if (node.hash && node.hash.pairs) {
193+
for (const pair of node.hash.pairs) {
194+
if (['elementId', 'id'].includes(pair.key)) {
195+
if (pair.value?.type === 'GlimmerStringLiteral') {
196+
logIfDuplicate(pair, pair.value.value);
197+
}
198+
}
199+
}
200+
}
201+
},
202+
203+
GlimmerBlockStatement(node) {
204+
if (isControlFlowHelper(node)) {
205+
enterConditional();
206+
} else if (node.hash && node.hash.pairs) {
207+
for (const pair of node.hash.pairs) {
208+
if (['elementId', 'id'].includes(pair.key)) {
209+
if (pair.value?.type === 'GlimmerStringLiteral') {
210+
logIfDuplicate(pair, pair.value.value);
211+
}
212+
}
213+
}
214+
}
215+
},
216+
217+
'GlimmerBlockStatement:exit'(node) {
218+
if (isControlFlowHelper(node)) {
219+
exitConditional();
220+
}
221+
},
222+
223+
GlimmerBlock(node) {
224+
const parent = node.parent;
225+
if (parent && isIfUnless(parent)) {
226+
enterConditionalBranch();
227+
}
228+
},
229+
230+
'GlimmerBlock:exit'(node) {
231+
const parent = node.parent;
232+
if (parent && isIfUnless(parent)) {
233+
exitConditionalBranch();
234+
}
235+
},
236+
};
237+
},
238+
};

0 commit comments

Comments
 (0)