Skip to content

Commit a7b61b0

Browse files
committed
Extract rule: template-no-shadowed-elements
1 parent 8a69d30 commit a7b61b0

4 files changed

Lines changed: 427 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ rules in templates can be disabled with eslint directives with mustache or html
409409
| :------------------------------------------------------------------------------------------- | :-------------------------------------------------------- | :- | :- | :- |
410410
| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | | | |
411411
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
412+
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow shadowing of built-in HTML elements | | | |
412413
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | | | |
413414

414415
### Routes
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# ember/template-no-shadowed-elements
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows usage patterns where component or block param names shadow built-in HTML elements, creating ambiguity.
6+
7+
## Rule Details
8+
9+
This rule prevents two kinds of shadowing:
10+
11+
1. **PascalCase components that shadow HTML elements** -- In `.gjs`/`.gts` files, a component like `<Form>` shadows the built-in `<form>` element. Use a more descriptive name instead.
12+
2. **Block params that shadow HTML elements** -- When a yielded block param has the same name as an HTML element (e.g. `as |div|`), using `<div>` inside that block is ambiguous. Use a different block param name instead.
13+
14+
## Examples
15+
16+
Examples of **incorrect** code for this rule:
17+
18+
```gjs
19+
<template>
20+
<Form>Content</Form>
21+
</template>
22+
```
23+
24+
```gjs
25+
<template>
26+
<Input @type="text" />
27+
</template>
28+
```
29+
30+
```gjs
31+
<template>
32+
<Select @options={{this.options}} />
33+
</template>
34+
```
35+
36+
```gjs
37+
<template>
38+
<FooBar as |div|>
39+
<div></div>
40+
</FooBar>
41+
</template>
42+
```
43+
44+
Examples of **correct** code for this rule:
45+
46+
```gjs
47+
<template>
48+
<CustomForm>Content</CustomForm>
49+
</template>
50+
```
51+
52+
```gjs
53+
<template>
54+
<TextInput @type="text" />
55+
</template>
56+
```
57+
58+
```gjs
59+
<template>
60+
<SelectBox @options={{this.options}} />
61+
</template>
62+
```
63+
64+
```gjs
65+
<template>
66+
<form>Regular HTML form</form>
67+
</template>
68+
```
69+
70+
```gjs
71+
<template>
72+
<FooBar as |Baz|>
73+
<Baz />
74+
</FooBar>
75+
</template>
76+
```
77+
78+
```gjs
79+
<template>
80+
<Foo as |bar|>
81+
<bar.baz />
82+
</Foo>
83+
</template>
84+
```
85+
86+
## References
87+
88+
- [eslint-plugin-ember template-no-shadowed-elements](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-shadowed-elements.md)
89+
- [Ember guides/block content](https://guides.emberjs.com/release/components/block-content/)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow shadowing of built-in HTML elements',
7+
category: 'Possible Errors',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-shadowed-elements.md',
9+
templateMode: 'both',
10+
},
11+
fixable: null,
12+
schema: [],
13+
messages: {
14+
shadowed: 'Component name "{{name}}" shadows HTML element <{{name}}>. Use a different name.',
15+
},
16+
originallyFrom: {
17+
name: 'ember-template-lint',
18+
rule: 'lib/rules/no-shadowed-elements.js',
19+
docs: 'docs/rule/no-shadowed-elements.md',
20+
tests: 'test/unit/rules/no-shadowed-elements-test.js',
21+
},
22+
},
23+
24+
create(context) {
25+
const HTML_ELEMENTS = new Set([
26+
'a',
27+
'abbr',
28+
'address',
29+
'area',
30+
'article',
31+
'aside',
32+
'audio',
33+
'b',
34+
'base',
35+
'bdi',
36+
'bdo',
37+
'blockquote',
38+
'body',
39+
'br',
40+
'button',
41+
'canvas',
42+
'caption',
43+
'cite',
44+
'code',
45+
'col',
46+
'colgroup',
47+
'data',
48+
'datalist',
49+
'dd',
50+
'del',
51+
'details',
52+
'dfn',
53+
'dialog',
54+
'div',
55+
'dl',
56+
'dt',
57+
'em',
58+
'embed',
59+
'fieldset',
60+
'figcaption',
61+
'figure',
62+
'footer',
63+
'form',
64+
'h1',
65+
'h2',
66+
'h3',
67+
'h4',
68+
'h5',
69+
'h6',
70+
'head',
71+
'header',
72+
'hgroup',
73+
'hr',
74+
'html',
75+
'i',
76+
'iframe',
77+
'img',
78+
'input',
79+
'ins',
80+
'kbd',
81+
'label',
82+
'legend',
83+
'li',
84+
'link',
85+
'main',
86+
'map',
87+
'mark',
88+
'meta',
89+
'meter',
90+
'nav',
91+
'noscript',
92+
'object',
93+
'ol',
94+
'optgroup',
95+
'option',
96+
'output',
97+
'p',
98+
'param',
99+
'picture',
100+
'pre',
101+
'progress',
102+
'q',
103+
'rp',
104+
'rt',
105+
'ruby',
106+
's',
107+
'samp',
108+
'script',
109+
'section',
110+
'select',
111+
'small',
112+
'source',
113+
'span',
114+
'strong',
115+
'style',
116+
'sub',
117+
'summary',
118+
'sup',
119+
'table',
120+
'tbody',
121+
'td',
122+
'template',
123+
'textarea',
124+
'tfoot',
125+
'th',
126+
'thead',
127+
'time',
128+
'title',
129+
'tr',
130+
'track',
131+
'u',
132+
'ul',
133+
'var',
134+
'video',
135+
'wbr',
136+
]);
137+
138+
const blockParamScope = [];
139+
140+
function pushScope(params) {
141+
blockParamScope.push(new Set(params || []));
142+
}
143+
144+
function popScope() {
145+
blockParamScope.pop();
146+
}
147+
148+
function isLocal(name) {
149+
for (const scope of blockParamScope) {
150+
if (scope.has(name)) {
151+
return true;
152+
}
153+
}
154+
return false;
155+
}
156+
157+
return {
158+
GlimmerBlockStatement(node) {
159+
if (node.program && node.program.blockParams) {
160+
pushScope(node.program.blockParams);
161+
}
162+
},
163+
'GlimmerBlockStatement:exit'(node) {
164+
if (node.program && node.program.blockParams) {
165+
popScope();
166+
}
167+
},
168+
169+
GlimmerElementNode(node) {
170+
// Push block params for elements with 'as |...|' syntax
171+
if (node.blockParams && node.blockParams.length > 0) {
172+
pushScope(node.blockParams);
173+
}
174+
175+
const tag = node.tag;
176+
if (!tag) {
177+
return;
178+
}
179+
180+
const firstChar = tag.charAt(0);
181+
const startsWithUpperCase =
182+
firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase();
183+
const containsDot = tag.includes('.');
184+
185+
if (containsDot) {
186+
// dot paths like bar.baz are not ambiguous
187+
return;
188+
}
189+
190+
if (startsWithUpperCase) {
191+
// PascalCase element
192+
if (isLocal(tag)) {
193+
// Local block param starting with uppercase → unambiguous → don't flag
194+
return;
195+
}
196+
// Not a local: check if it shadows HTML element
197+
const lowerTag = tag.toLowerCase();
198+
if (HTML_ELEMENTS.has(lowerTag)) {
199+
context.report({
200+
node,
201+
messageId: 'shadowed',
202+
data: { name: lowerTag },
203+
});
204+
}
205+
} else {
206+
// Lowercase element - check if it's a local that shadows HTML element
207+
if (isLocal(tag) && HTML_ELEMENTS.has(tag)) {
208+
context.report({
209+
node,
210+
messageId: 'shadowed',
211+
data: { name: tag },
212+
});
213+
}
214+
}
215+
},
216+
217+
'GlimmerElementNode:exit'(node) {
218+
if (node.blockParams && node.blockParams.length > 0) {
219+
popScope();
220+
}
221+
},
222+
};
223+
},
224+
};

0 commit comments

Comments
 (0)