Skip to content

Commit a19a2b5

Browse files
committed
Extract rule: template-no-duplicate-id
1 parent 0149ef1 commit a19a2b5

4 files changed

Lines changed: 571 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ rules in templates can be disabled with eslint directives with mustache or html
198198
| [template-no-block-params-for-html-elements](docs/rules/template-no-block-params-for-html-elements.md) | disallow block params on HTML elements | | | |
199199
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
200200
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
201+
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
201202
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
202203
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
203204

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: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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+
},
31+
create(context) {
32+
const sourceCode = context.getSourceCode();
33+
// Stack-based conditional scoping to handle if/else branches
34+
let seenIdStack = [];
35+
let conditionalStack = [];
36+
37+
function enterTemplate() {
38+
seenIdStack = [new Set()];
39+
conditionalStack = [];
40+
}
41+
42+
function isDuplicateId(id) {
43+
for (const seenIds of seenIdStack) {
44+
if (seenIds.has(id)) {
45+
return true;
46+
}
47+
}
48+
return false;
49+
}
50+
51+
function addId(id) {
52+
seenIdStack.at(-1).add(id);
53+
if (conditionalStack.length > 0) {
54+
conditionalStack.at(-1).add(id);
55+
}
56+
}
57+
58+
function enterConditional() {
59+
conditionalStack.push(new Set());
60+
}
61+
62+
function exitConditional() {
63+
const idsInConditional = conditionalStack.pop();
64+
if (conditionalStack.length > 0) {
65+
for (const id of idsInConditional) {
66+
conditionalStack.at(-1).add(id);
67+
}
68+
} else {
69+
seenIdStack.push(idsInConditional);
70+
}
71+
}
72+
73+
function enterConditionalBranch() {
74+
seenIdStack.push(new Set());
75+
}
76+
77+
function exitConditionalBranch() {
78+
seenIdStack.pop();
79+
}
80+
81+
function resolveIdValue(valueNode, node) {
82+
if (!valueNode) {
83+
return null;
84+
}
85+
86+
switch (valueNode.type) {
87+
case 'GlimmerTextNode': {
88+
return valueNode.chars || null;
89+
}
90+
case 'GlimmerStringLiteral': {
91+
return valueNode.value || null;
92+
}
93+
case 'GlimmerMustacheStatement': {
94+
// Try to resolve {{...}} - if it's a string literal path, use value
95+
if (valueNode.path?.type === 'GlimmerStringLiteral') {
96+
return valueNode.path.value;
97+
}
98+
// For path expressions, use the source text as a best-effort unique key
99+
return sourceCode.getText(valueNode);
100+
}
101+
case 'GlimmerConcatStatement': {
102+
// Concatenate resolved parts
103+
if (valueNode.parts) {
104+
return valueNode.parts
105+
.map((part) => {
106+
if (part.type === 'GlimmerTextNode') {
107+
return part.chars;
108+
}
109+
return sourceCode.getText(part);
110+
})
111+
.join('');
112+
}
113+
return sourceCode.getText(valueNode);
114+
}
115+
default: {
116+
return sourceCode.getText(valueNode);
117+
}
118+
}
119+
}
120+
121+
function logIfDuplicate(reportNode, id) {
122+
if (!id) {
123+
return;
124+
}
125+
if (IGNORE_IDS.has(id)) {
126+
return;
127+
}
128+
if (isDuplicateId(id)) {
129+
context.report({ node: reportNode, messageId: 'duplicate' });
130+
} else {
131+
addId(id);
132+
}
133+
}
134+
135+
return {
136+
GlimmerTemplate() {
137+
enterTemplate();
138+
},
139+
'GlimmerTemplate:exit'() {
140+
seenIdStack = [new Set()];
141+
conditionalStack = [];
142+
},
143+
144+
GlimmerElementNode(node) {
145+
// Check id, @id, @elementId attributes
146+
const idAttrNames = new Set(['id', '@id', '@elementId']);
147+
for (const attr of node.attributes || []) {
148+
if (idAttrNames.has(attr.name)) {
149+
const id = resolveIdValue(attr.value, node);
150+
logIfDuplicate(attr, id);
151+
}
152+
}
153+
},
154+
155+
// Handle hash pairs in mustache/block statements (e.g., {{input elementId="foo"}})
156+
GlimmerMustacheStatement(node) {
157+
if (node.hash && node.hash.pairs) {
158+
for (const pair of node.hash.pairs) {
159+
if (['elementId', 'id'].includes(pair.key)) {
160+
if (pair.value?.type === 'GlimmerStringLiteral') {
161+
logIfDuplicate(pair, pair.value.value);
162+
}
163+
}
164+
}
165+
}
166+
},
167+
168+
GlimmerBlockStatement(node) {
169+
if (isControlFlowHelper(node)) {
170+
enterConditional();
171+
} else if (node.hash && node.hash.pairs) {
172+
for (const pair of node.hash.pairs) {
173+
if (['elementId', 'id'].includes(pair.key)) {
174+
if (pair.value?.type === 'GlimmerStringLiteral') {
175+
logIfDuplicate(pair, pair.value.value);
176+
}
177+
}
178+
}
179+
}
180+
},
181+
182+
'GlimmerBlockStatement:exit'(node) {
183+
if (isControlFlowHelper(node)) {
184+
exitConditional();
185+
}
186+
},
187+
188+
GlimmerBlock(node) {
189+
const parent = node.parent;
190+
if (parent && isIfUnless(parent)) {
191+
enterConditionalBranch();
192+
}
193+
},
194+
195+
'GlimmerBlock:exit'(node) {
196+
const parent = node.parent;
197+
if (parent && isIfUnless(parent)) {
198+
exitConditionalBranch();
199+
}
200+
},
201+
};
202+
},
203+
};

0 commit comments

Comments
 (0)