Skip to content

Commit 2a564fb

Browse files
committed
Extract rule: template-no-duplicate-id
1 parent 484c3c6 commit 2a564fb

File tree

4 files changed

+607
-0
lines changed

4 files changed

+607
-0
lines changed

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: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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+
strictGjs: true,
25+
strictGts: true,
26+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-id.md',
27+
},
28+
schema: [],
29+
messages: { duplicate: 'ID attribute values must be unique' },
30+
originallyFrom: {
31+
name: 'ember-template-lint',
32+
rule: 'lib/rules/no-duplicate-id.js',
33+
docs: 'docs/rule/no-duplicate-id.md',
34+
tests: 'test/unit/rules/no-duplicate-id-test.js',
35+
},
36+
},
37+
create(context) {
38+
const sourceCode = context.getSourceCode();
39+
// Stack-based conditional scoping to handle if/else branches
40+
let seenIdStack = [];
41+
let conditionalStack = [];
42+
let conditionalReportedDuplicates = [];
43+
44+
function enterTemplate() {
45+
seenIdStack = [new Set()];
46+
conditionalStack = [];
47+
conditionalReportedDuplicates = [];
48+
}
49+
50+
function isDuplicateId(id) {
51+
for (const seenIds of seenIdStack) {
52+
if (seenIds.has(id)) {
53+
return true;
54+
}
55+
}
56+
return false;
57+
}
58+
59+
function addId(id) {
60+
seenIdStack.at(-1).add(id);
61+
if (conditionalStack.length > 0) {
62+
conditionalStack.at(-1).add(id);
63+
}
64+
}
65+
66+
function enterConditional() {
67+
conditionalStack.push(new Set());
68+
conditionalReportedDuplicates.push(new Set());
69+
}
70+
71+
function exitConditional() {
72+
const idsInConditional = conditionalStack.pop();
73+
conditionalReportedDuplicates.pop();
74+
if (conditionalStack.length > 0) {
75+
for (const id of idsInConditional) {
76+
conditionalStack.at(-1).add(id);
77+
}
78+
} else {
79+
seenIdStack.push(idsInConditional);
80+
}
81+
}
82+
83+
function enterConditionalBranch() {
84+
seenIdStack.push(new Set());
85+
}
86+
87+
function exitConditionalBranch() {
88+
seenIdStack.pop();
89+
}
90+
91+
function resolveIdValue(valueNode, node) {
92+
if (!valueNode) {
93+
return null;
94+
}
95+
96+
switch (valueNode.type) {
97+
case 'GlimmerTextNode': {
98+
return valueNode.chars || null;
99+
}
100+
case 'GlimmerStringLiteral': {
101+
return valueNode.value || null;
102+
}
103+
case 'GlimmerMustacheStatement': {
104+
// Try to resolve {{...}} - if it's a string literal path, use value
105+
if (valueNode.path?.type === 'GlimmerStringLiteral') {
106+
return valueNode.path.value;
107+
}
108+
// For path expressions, use the source text as a best-effort unique key
109+
return sourceCode.getText(valueNode);
110+
}
111+
case 'GlimmerConcatStatement': {
112+
// Concatenate resolved parts
113+
if (valueNode.parts) {
114+
return valueNode.parts
115+
.map((part) => {
116+
if (part.type === 'GlimmerTextNode') {
117+
return part.chars;
118+
}
119+
if (
120+
part.type === 'GlimmerMustacheStatement' &&
121+
part.path?.type === 'GlimmerStringLiteral'
122+
) {
123+
return part.path.value;
124+
}
125+
return sourceCode.getText(part);
126+
})
127+
.join('');
128+
}
129+
return sourceCode.getText(valueNode);
130+
}
131+
default: {
132+
return sourceCode.getText(valueNode);
133+
}
134+
}
135+
}
136+
137+
function logIfDuplicate(reportNode, id) {
138+
if (!id) {
139+
return;
140+
}
141+
if (IGNORE_IDS.has(id)) {
142+
return;
143+
}
144+
if (isDuplicateId(id)) {
145+
// If inside a conditional, only report each duplicate ID once across branches
146+
if (conditionalReportedDuplicates.length > 0) {
147+
const reported = conditionalReportedDuplicates.at(-1);
148+
if (reported.has(id)) {
149+
return;
150+
}
151+
reported.add(id);
152+
}
153+
context.report({ node: reportNode, messageId: 'duplicate' });
154+
} else {
155+
addId(id);
156+
}
157+
}
158+
159+
return {
160+
GlimmerTemplate() {
161+
enterTemplate();
162+
},
163+
'GlimmerTemplate:exit'() {
164+
seenIdStack = [new Set()];
165+
conditionalStack = [];
166+
conditionalReportedDuplicates = [];
167+
},
168+
169+
GlimmerElementNode(node) {
170+
// If element has block params, enter a scope
171+
if (node.blockParams && node.blockParams.length > 0) {
172+
seenIdStack.push(new Set());
173+
}
174+
175+
// Check id, @id, @elementId attributes
176+
const idAttrNames = new Set(['id', '@id', '@elementId']);
177+
for (const attr of node.attributes || []) {
178+
if (idAttrNames.has(attr.name)) {
179+
const id = resolveIdValue(attr.value, node);
180+
logIfDuplicate(attr, id);
181+
}
182+
}
183+
},
184+
185+
'GlimmerElementNode:exit'(node) {
186+
if (node.blockParams && node.blockParams.length > 0) {
187+
seenIdStack.pop();
188+
}
189+
},
190+
191+
// Handle hash pairs in mustache/block statements (e.g., {{input elementId="foo"}})
192+
GlimmerMustacheStatement(node) {
193+
if (node.hash && node.hash.pairs) {
194+
for (const pair of node.hash.pairs) {
195+
if (['elementId', 'id'].includes(pair.key)) {
196+
if (pair.value?.type === 'GlimmerStringLiteral') {
197+
logIfDuplicate(pair, pair.value.value);
198+
}
199+
}
200+
}
201+
}
202+
},
203+
204+
GlimmerBlockStatement(node) {
205+
if (isControlFlowHelper(node)) {
206+
enterConditional();
207+
} else if (node.hash && node.hash.pairs) {
208+
for (const pair of node.hash.pairs) {
209+
if (['elementId', 'id'].includes(pair.key)) {
210+
if (pair.value?.type === 'GlimmerStringLiteral') {
211+
logIfDuplicate(pair, pair.value.value);
212+
}
213+
}
214+
}
215+
}
216+
},
217+
218+
'GlimmerBlockStatement:exit'(node) {
219+
if (isControlFlowHelper(node)) {
220+
exitConditional();
221+
}
222+
},
223+
224+
GlimmerBlock(node) {
225+
const parent = node.parent;
226+
if (parent && isIfUnless(parent)) {
227+
enterConditionalBranch();
228+
}
229+
},
230+
231+
'GlimmerBlock:exit'(node) {
232+
const parent = node.parent;
233+
if (parent && isIfUnless(parent)) {
234+
exitConditionalBranch();
235+
}
236+
},
237+
};
238+
},
239+
};

0 commit comments

Comments
 (0)