Skip to content

Commit e083073

Browse files
committed
refactor: align component check with ember-cli#2724 canonical (lists + scope)
Remove the inline PascalCase-regex isComponentInvocation heuristic in favor of the lists + scope pattern from ember-cli#2689 / ember-cli#2724. The new lib/utils/is-native-element.js mirrors ember-cli#2724's util byte-for-byte so the two PRs can land in either order without conflict. evaluateChild / evaluateChildren now thread sourceCode through the recursion so the scope check has access to the enclosing template's bindings. Heuristic approaches were explicitly rejected in ember-cli#2689 because lowercase tags CAN be components when shadowed by scope bindings. Treating custom elements as opaque (the same as components) is a behavior improvement — matches ember-cli#2724's convention.
1 parent b0c0af7 commit e083073

3 files changed

Lines changed: 162 additions & 24 deletions

File tree

lib/rules/template-anchor-has-content.js

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,4 @@
1-
// Matches a tag string that is a component invocation rather than a plain
2-
// HTML element: PascalCase (`Foo`), argument-invocation (`@foo`), path on
3-
// `this.` (`this.foo`), dotted path (`foo.bar`), or named-block-style
4-
// `foo::bar`. Keep this mirrored with the inline pattern in
5-
// lib/rules/template-no-invalid-interactive.js until a shared utility lands.
6-
function isComponentInvocation(tag) {
7-
if (!tag) {
8-
return false;
9-
}
10-
return (
11-
/^[A-Z]/.test(tag) ||
12-
tag.startsWith('@') ||
13-
tag.startsWith('this.') ||
14-
tag.includes('.') ||
15-
tag.includes('::')
16-
);
17-
}
1+
const { isNativeElement } = require('../utils/is-native-element');
182

193
function isDynamicValue(value) {
204
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
@@ -73,7 +57,7 @@ function hasAccessibleNameAttribute(node) {
7357
// { accessible: true } — statically contributes a non-empty name.
7458
// { accessible: false } — contributes nothing (empty text, aria-hidden
7559
// subtree, <img> without non-empty alt, …).
76-
function evaluateChild(child) {
60+
function evaluateChild(child, sourceCode) {
7761
if (child.type === 'GlimmerTextNode') {
7862
const text = child.chars.replaceAll('&nbsp;', ' ').trim();
7963
return { dynamic: false, accessible: text.length > 0 };
@@ -97,8 +81,9 @@ function evaluateChild(child) {
9781
return { dynamic: false, accessible: false };
9882
}
9983

100-
// Component invocations are opaque — we can't see inside them.
101-
if (isComponentInvocation(child.tag)) {
84+
// Non-native children (components, custom elements, scope-shadowed tags)
85+
// are opaque — we can't see inside them.
86+
if (!isNativeElement(child, sourceCode)) {
10287
return { dynamic: true, accessible: false };
10388
}
10489

@@ -124,16 +109,16 @@ function evaluateChild(child) {
124109
return { dynamic: false, accessible: true };
125110
}
126111

127-
return evaluateChildren(child.children || []);
112+
return evaluateChildren(child.children || [], sourceCode);
128113
}
129114

130115
return { dynamic: false, accessible: false };
131116
}
132117

133-
function evaluateChildren(children) {
118+
function evaluateChildren(children, sourceCode) {
134119
let dynamic = false;
135120
for (const child of children) {
136-
const result = evaluateChild(child);
121+
const result = evaluateChild(child, sourceCode);
137122
if (result.accessible) {
138123
return { dynamic: false, accessible: true };
139124
}
@@ -162,6 +147,7 @@ module.exports = {
162147
},
163148

164149
create(context) {
150+
const sourceCode = context.sourceCode || context.getSourceCode();
165151
return {
166152
GlimmerElementNode(node) {
167153
if (node.tag !== 'a') {
@@ -179,7 +165,7 @@ module.exports = {
179165
return;
180166
}
181167

182-
const result = evaluateChildren(node.children || []);
168+
const result = evaluateChildren(node.children || [], sourceCode);
183169
if (result.accessible || result.dynamic) {
184170
return;
185171
}

lib/utils/is-native-element.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
'use strict';
2+
3+
const htmlTags = require('html-tags');
4+
const svgTags = require('svg-tags');
5+
const { mathmlTagNames } = require('mathml-tag-names');
6+
7+
// Authoritative set of native element tag names. Mirrors the approach
8+
// established by #2689 (template-no-block-params-for-html-elements), which
9+
// the maintainer requires for component-vs-element discrimination in this
10+
// plugin. Heuristic approaches (PascalCase detection, etc.) were explicitly
11+
// rejected there because a lowercase tag CAN be a component in GJS/GTS when
12+
// the name is bound in scope (e.g. `const div = MyComponent; <div />`).
13+
const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
14+
15+
/**
16+
* Returns true if the Glimmer element node is a native HTML / SVG / MathML
17+
* element — i.e. the tag name is in the authoritative list AND is not
18+
* shadowed by an in-scope binding.
19+
*
20+
* Returns false for:
21+
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
22+
* none of these tag names appear in the HTML/SVG/MathML lists)
23+
* - custom elements (`<my-widget>`) — accepted false negative; the web-
24+
* components namespace is open and can't be enumerated
25+
* - scope-bound identifiers (`<div>` when `div` is a local `let` / `const` /
26+
* import / block-param in the enclosing scope)
27+
*
28+
* @param {object} node - GlimmerElementNode
29+
* @param {object} [sourceCode] - ESLint SourceCode, for scope lookup. When
30+
* omitted, the scope check is skipped (the result is then list-based only —
31+
* suitable for unit tests).
32+
*/
33+
function isNativeElement(node, sourceCode) {
34+
if (!node || typeof node.tag !== 'string') {
35+
return false;
36+
}
37+
if (!ELEMENT_TAGS.has(node.tag)) {
38+
return false;
39+
}
40+
if (!sourceCode || !node.parent) {
41+
return true;
42+
}
43+
const scope = sourceCode.getScope(node.parent);
44+
const firstPart = node.parts && node.parts[0];
45+
if (firstPart && scope.references.some((ref) => ref.identifier === firstPart)) {
46+
return false;
47+
}
48+
return true;
49+
}
50+
51+
/**
52+
* Inverse of {@link isNativeElement}. Returns true when the node should NOT
53+
* be treated as a native HTML element — either because it's a component
54+
* invocation (PascalCase, dotted, @-prefixed, this.-prefixed, custom element)
55+
* OR a tag name that's shadowed by a scope binding.
56+
*/
57+
function isComponentInvocation(node, sourceCode) {
58+
return !isNativeElement(node, sourceCode);
59+
}
60+
61+
module.exports = { isNativeElement, isComponentInvocation, ELEMENT_TAGS };
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use strict';
2+
3+
const { isNativeElement, ELEMENT_TAGS } = require('../../../lib/utils/is-native-element');
4+
5+
// Tests exercise the list-lookup path only. Scope-based shadowing is covered
6+
// by the rule-level test suites (see tests/lib/rules/template-no-block-params-
7+
// for-html-elements.js and siblings) because it requires a real ESLint
8+
// SourceCode / scope manager that's only built up by the rule tester.
9+
10+
describe('isNativeElement — list-only behavior (no sourceCode)', () => {
11+
it('returns true for lowercase HTML tag names', () => {
12+
expect(isNativeElement({ tag: 'div' })).toBe(true);
13+
expect(isNativeElement({ tag: 'article' })).toBe(true);
14+
expect(isNativeElement({ tag: 'h1' })).toBe(true);
15+
expect(isNativeElement({ tag: 'button' })).toBe(true);
16+
expect(isNativeElement({ tag: 'form' })).toBe(true);
17+
expect(isNativeElement({ tag: 'section' })).toBe(true);
18+
});
19+
20+
it('returns true for SVG tag names', () => {
21+
expect(isNativeElement({ tag: 'svg' })).toBe(true);
22+
expect(isNativeElement({ tag: 'circle' })).toBe(true);
23+
expect(isNativeElement({ tag: 'path' })).toBe(true);
24+
});
25+
26+
it('returns true for MathML tag names', () => {
27+
expect(isNativeElement({ tag: 'mfrac' })).toBe(true);
28+
expect(isNativeElement({ tag: 'math' })).toBe(true);
29+
});
30+
31+
it('returns false for PascalCase component tags', () => {
32+
expect(isNativeElement({ tag: 'Button' })).toBe(false);
33+
expect(isNativeElement({ tag: 'MyWidget' })).toBe(false);
34+
// Native-tag names in PascalCase — the core bug case.
35+
expect(isNativeElement({ tag: 'Article' })).toBe(false);
36+
expect(isNativeElement({ tag: 'Form' })).toBe(false);
37+
expect(isNativeElement({ tag: 'Main' })).toBe(false);
38+
expect(isNativeElement({ tag: 'Nav' })).toBe(false);
39+
expect(isNativeElement({ tag: 'Section' })).toBe(false);
40+
expect(isNativeElement({ tag: 'Table' })).toBe(false);
41+
});
42+
43+
it('returns false for named-arg invocations', () => {
44+
expect(isNativeElement({ tag: '@heading' })).toBe(false);
45+
expect(isNativeElement({ tag: '@tag.foo' })).toBe(false);
46+
});
47+
48+
it('returns false for this-path invocations', () => {
49+
expect(isNativeElement({ tag: 'this.myComponent' })).toBe(false);
50+
expect(isNativeElement({ tag: 'this.comp.sub' })).toBe(false);
51+
});
52+
53+
it('returns false for dot-path invocations', () => {
54+
expect(isNativeElement({ tag: 'foo.bar' })).toBe(false);
55+
expect(isNativeElement({ tag: 'ns.widget' })).toBe(false);
56+
});
57+
58+
it('returns false for named-block / namespaced invocations', () => {
59+
expect(isNativeElement({ tag: 'foo::bar' })).toBe(false);
60+
expect(isNativeElement({ tag: 'Foo::Bar' })).toBe(false);
61+
});
62+
63+
it('returns false for custom elements (accepted false negative)', () => {
64+
// Custom elements aren't in the html-tags/svg-tags/mathml-tag-names
65+
// allowlists. They're treated as "not a native element" so downstream
66+
// rules skip them — matches the convention established in PR #2689.
67+
expect(isNativeElement({ tag: 'my-element' })).toBe(false);
68+
expect(isNativeElement({ tag: 'x-foo' })).toBe(false);
69+
});
70+
71+
it('returns false for empty / missing / non-string tag', () => {
72+
expect(isNativeElement({ tag: '' })).toBe(false);
73+
expect(isNativeElement({ tag: undefined })).toBe(false);
74+
expect(isNativeElement({ tag: null })).toBe(false);
75+
expect(isNativeElement({ tag: 123 })).toBe(false);
76+
expect(isNativeElement({})).toBe(false);
77+
expect(isNativeElement()).toBe(false);
78+
expect(isNativeElement(null)).toBe(false);
79+
});
80+
});
81+
82+
describe('ELEMENT_TAGS', () => {
83+
it('includes all HTML, SVG, and MathML tag names', () => {
84+
// Sanity check — if this ever drops below a reasonable size, one of the
85+
// underlying packages has changed contract.
86+
expect(ELEMENT_TAGS.size).toBeGreaterThan(200);
87+
expect(ELEMENT_TAGS.has('div')).toBe(true);
88+
expect(ELEMENT_TAGS.has('circle')).toBe(true);
89+
expect(ELEMENT_TAGS.has('mfrac')).toBe(true);
90+
});
91+
});

0 commit comments

Comments
 (0)