Skip to content

Commit 472dbb0

Browse files
Merge pull request #2426 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-duplicate-id
Extract rule: template-no-duplicate-id
2 parents 5a482d5 + a1ae7bd commit 472dbb0

4 files changed

Lines changed: 917 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ rules in templates can be disabled with eslint directives with mustache or html
214214
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
215215
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
216216
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
217+
| [template-no-duplicate-id](docs/rules/template-no-duplicate-id.md) | disallow duplicate id attributes | | | |
217218
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
218219
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
219220
| [template-no-forbidden-elements](docs/rules/template-no-forbidden-elements.md) | disallow specific HTML elements | | | |
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: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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+
// Walk up the parent chain to find an ancestor element/block whose blockParams
18+
// include headName. Used to make block-param-derived IDs unique per invocation.
19+
function findBlockParamAncestor(node, headName) {
20+
if (!headName) {
21+
return null;
22+
}
23+
let p = node.parent;
24+
while (p) {
25+
if (p.type === 'GlimmerElementNode' && p.blockParams?.includes(headName)) {
26+
return p;
27+
}
28+
if (p.type === 'GlimmerBlockStatement' && p.program?.blockParams?.includes(headName)) {
29+
return p;
30+
}
31+
p = p.parent;
32+
}
33+
return null;
34+
}
35+
36+
/** @type {import('eslint').Rule.RuleModule} */
37+
module.exports = {
38+
meta: {
39+
type: 'problem',
40+
docs: {
41+
description: 'disallow duplicate id attributes',
42+
category: 'Best Practices',
43+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-duplicate-id.md',
44+
templateMode: 'both',
45+
},
46+
schema: [],
47+
messages: { duplicate: 'ID attribute values must be unique' },
48+
originallyFrom: {
49+
name: 'ember-template-lint',
50+
rule: 'lib/rules/no-duplicate-id.js',
51+
docs: 'docs/rule/no-duplicate-id.md',
52+
tests: 'test/unit/rules/no-duplicate-id-test.js',
53+
},
54+
},
55+
create(context) {
56+
const sourceCode = context.getSourceCode();
57+
let seenIdStack = [];
58+
let conditionalStack = [];
59+
60+
function enterTemplate() {
61+
seenIdStack = [new Set()];
62+
conditionalStack = [];
63+
}
64+
65+
function isDuplicateId(id) {
66+
for (const seenIds of seenIdStack) {
67+
if (seenIds.has(id)) {
68+
return true;
69+
}
70+
}
71+
return false;
72+
}
73+
74+
function addId(id) {
75+
seenIdStack.at(-1).add(id);
76+
if (conditionalStack.length > 0) {
77+
conditionalStack.at(-1).add(id);
78+
}
79+
}
80+
81+
function enterConditional() {
82+
conditionalStack.push(new Set());
83+
}
84+
85+
function exitConditional() {
86+
const idsInConditional = conditionalStack.pop();
87+
if (conditionalStack.length > 0) {
88+
for (const id of idsInConditional) {
89+
conditionalStack.at(-1).add(id);
90+
}
91+
} else {
92+
seenIdStack.push(idsInConditional);
93+
}
94+
}
95+
96+
function enterConditionalBranch() {
97+
seenIdStack.push(new Set());
98+
}
99+
100+
function exitConditionalBranch() {
101+
seenIdStack.pop();
102+
}
103+
104+
// For a GlimmerMustacheStatement whose path is a PathExpression, return a
105+
// location-aware key so that the same expression in different component
106+
// invocations (e.g. {{inputProperties.id}} in two <MyComponent> blocks) gets
107+
// a distinct key, while two occurrences inside the SAME block get the same key.
108+
function getMustachePathKey(valueNode) {
109+
const sourceText = sourceCode.getText(valueNode);
110+
if (valueNode.path?.type === 'GlimmerPathExpression') {
111+
const headName = valueNode.path.original.split('.')[0];
112+
const ancestor = findBlockParamAncestor(valueNode, headName);
113+
if (ancestor) {
114+
const loc = ancestor.loc?.start;
115+
const tag = ancestor.tag ?? ancestor.path?.original ?? '';
116+
return `${sourceText}${tag}${loc ? `${loc.line}:${loc.column}` : ''}`;
117+
}
118+
}
119+
return sourceText;
120+
}
121+
122+
function resolveIdValue(valueNode) {
123+
if (!valueNode) {
124+
return null;
125+
}
126+
127+
switch (valueNode.type) {
128+
case 'GlimmerTextNode': {
129+
return valueNode.chars || null;
130+
}
131+
case 'GlimmerStringLiteral': {
132+
return valueNode.value || null;
133+
}
134+
case 'GlimmerMustacheStatement': {
135+
if (valueNode.path?.type === 'GlimmerStringLiteral') {
136+
return valueNode.path.value;
137+
}
138+
return getMustachePathKey(valueNode);
139+
}
140+
case 'GlimmerConcatStatement': {
141+
if (valueNode.parts) {
142+
return valueNode.parts
143+
.map((part) => {
144+
if (part.type === 'GlimmerTextNode') {
145+
return part.chars;
146+
}
147+
if (
148+
part.type === 'GlimmerMustacheStatement' &&
149+
part.path?.type === 'GlimmerStringLiteral'
150+
) {
151+
return part.path.value;
152+
}
153+
if (part.type === 'GlimmerMustacheStatement') {
154+
return getMustachePathKey(part);
155+
}
156+
return sourceCode.getText(part);
157+
})
158+
.join('');
159+
}
160+
return sourceCode.getText(valueNode);
161+
}
162+
default: {
163+
return sourceCode.getText(valueNode);
164+
}
165+
}
166+
}
167+
168+
function logIfDuplicate(reportNode, id) {
169+
if (!id) {
170+
return;
171+
}
172+
if (IGNORE_IDS.has(id)) {
173+
return;
174+
}
175+
if (isDuplicateId(id)) {
176+
context.report({ node: reportNode, messageId: 'duplicate' });
177+
} else {
178+
addId(id);
179+
}
180+
}
181+
182+
return {
183+
GlimmerTemplate() {
184+
enterTemplate();
185+
},
186+
'GlimmerTemplate:exit'() {
187+
seenIdStack = [new Set()];
188+
conditionalStack = [];
189+
},
190+
191+
GlimmerElementNode(node) {
192+
// Note: no blockParams scoping here. Elements with block params (e.g.
193+
// <MyComponent as |foo|>) must not isolate their static IDs — a static
194+
// "shared-id" inside and outside such an element ARE duplicates.
195+
const idAttrNames = new Set(['id', '@id', '@elementId']);
196+
for (const attr of node.attributes || []) {
197+
if (idAttrNames.has(attr.name)) {
198+
const id = resolveIdValue(attr.value);
199+
logIfDuplicate(attr, id);
200+
}
201+
}
202+
},
203+
204+
// Handle hash pairs in mustache/block statements (e.g., {{input elementId="foo"}})
205+
GlimmerMustacheStatement(node) {
206+
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(node) {
218+
if (isControlFlowHelper(node)) {
219+
enterConditional();
220+
} else if (node.hash && node.hash.pairs) {
221+
for (const pair of node.hash.pairs) {
222+
if (['elementId', 'id'].includes(pair.key)) {
223+
if (pair.value?.type === 'GlimmerStringLiteral') {
224+
logIfDuplicate(pair, pair.value.value);
225+
}
226+
}
227+
}
228+
}
229+
},
230+
231+
'GlimmerBlockStatement:exit'(node) {
232+
if (isControlFlowHelper(node)) {
233+
exitConditional();
234+
}
235+
},
236+
237+
GlimmerBlock(node) {
238+
const parent = node.parent;
239+
if (parent && isIfUnless(parent)) {
240+
enterConditionalBranch();
241+
}
242+
},
243+
244+
'GlimmerBlock:exit'(node) {
245+
const parent = node.parent;
246+
if (parent && isIfUnless(parent)) {
247+
exitConditionalBranch();
248+
}
249+
},
250+
};
251+
},
252+
};

0 commit comments

Comments
 (0)