Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { isComponentInvocation } = require('../utils/is-component-invocation');

const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);

function isHidden(node) {
Expand All @@ -18,16 +20,7 @@ function isComponent(node) {
if (node.type !== 'GlimmerElementNode') {
return false;
}
const tag = node.tag;
// PascalCase (<MyComponent>), namespaced (<Foo::Bar>), this.-prefixed
// (<this.Component>), arg-prefixed (<@component>), or dot-path (<ns.Widget>)
return (
/^[A-Z]/.test(tag) ||
tag.includes('::') ||
tag.startsWith('this.') ||
tag.startsWith('@') ||
tag.includes('.')
);
return isComponentInvocation(node);
}

function isTextEmpty(text) {
Expand Down
12 changes: 5 additions & 7 deletions lib/rules/template-no-invalid-interactive.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { isComponentInvocation } = require('../utils/is-component-invocation');

function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}
Expand Down Expand Up @@ -179,13 +181,9 @@ module.exports = {
return;
}

// Skip components (PascalCase, @-prefixed, this.-prefixed, path-based like foo.bar)
if (
/^[A-Z]/.test(node.tag) ||
node.tag.startsWith('@') ||
node.tag.startsWith('this.') ||
node.tag.includes('.')
) {
// Skip component invocations (PascalCase, @-prefixed, this.-prefixed,
// dot-path, named-block) — only native HTML elements can be misclassified.
if (isComponentInvocation(node)) {
return;
}

Expand Down
24 changes: 24 additions & 0 deletions lib/utils/is-component-invocation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

/**
* Returns true if the Glimmer element node is a component invocation
* rather than a native HTML element. Excludes:
* - PascalCase tags (<Button>, <MyWidget>)
* - Named-arg invocations (<@heading>, <@tag.foo>)
* - This-path invocations (<this.myComponent>, <this.comp.sub>)
* - Dot-path invocations (<foo.bar>)
* - Named-block syntax (<foo::bar>)
*/
module.exports.isComponentInvocation = function isComponentInvocation(node) {
const tag = node?.tag;
if (typeof tag !== 'string') {
return false;
}
return (
/^[A-Z]/.test(tag) ||
tag.startsWith('@') ||
tag.startsWith('this.') ||
tag.includes('.') ||
tag.includes('::')
);
};
68 changes: 68 additions & 0 deletions tests/lib/utils/is-component-invocation-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use strict';

const { isComponentInvocation } = require('../../../lib/utils/is-component-invocation');

describe('isComponentInvocation', () => {
it('returns true for PascalCase tags', () => {
expect(isComponentInvocation({ tag: 'Button' })).toBe(true);
expect(isComponentInvocation({ tag: 'MyWidget' })).toBe(true);
// PascalCase tags that match a native HTML element name — the core bug case
expect(isComponentInvocation({ tag: 'Article' })).toBe(true);
expect(isComponentInvocation({ tag: 'Form' })).toBe(true);
expect(isComponentInvocation({ tag: 'Main' })).toBe(true);
expect(isComponentInvocation({ tag: 'Nav' })).toBe(true);
expect(isComponentInvocation({ tag: 'Ul' })).toBe(true);
expect(isComponentInvocation({ tag: 'Li' })).toBe(true);
expect(isComponentInvocation({ tag: 'Aside' })).toBe(true);
expect(isComponentInvocation({ tag: 'Section' })).toBe(true);
expect(isComponentInvocation({ tag: 'Table' })).toBe(true);
});

it('returns false for lowercase native HTML tags', () => {
expect(isComponentInvocation({ tag: 'div' })).toBe(false);
expect(isComponentInvocation({ tag: 'article' })).toBe(false);
expect(isComponentInvocation({ tag: 'form' })).toBe(false);
expect(isComponentInvocation({ tag: 'h1' })).toBe(false);
expect(isComponentInvocation({ tag: 'button' })).toBe(false);
});

it('returns true for named-arg invocations', () => {
expect(isComponentInvocation({ tag: '@heading' })).toBe(true);
expect(isComponentInvocation({ tag: '@tag.foo' })).toBe(true);
});

it('returns true for this-path invocations', () => {
expect(isComponentInvocation({ tag: 'this.myComponent' })).toBe(true);
expect(isComponentInvocation({ tag: 'this.comp.sub' })).toBe(true);
});

it('returns true for dot-path invocations', () => {
expect(isComponentInvocation({ tag: 'foo.bar' })).toBe(true);
expect(isComponentInvocation({ tag: 'ns.widget' })).toBe(true);
});

it('returns true for named-block / namespaced invocations', () => {
expect(isComponentInvocation({ tag: 'foo::bar' })).toBe(true);
expect(isComponentInvocation({ tag: 'Foo::Bar' })).toBe(true);
});

it('returns false for empty-string tag', () => {
expect(isComponentInvocation({ tag: '' })).toBe(false);
});

it('returns false for undefined node', () => {
expect(isComponentInvocation()).toBe(false);
expect(isComponentInvocation(undefined)).toBe(false);
expect(isComponentInvocation(null)).toBe(false);
});

it('returns false for node with undefined tag', () => {
expect(isComponentInvocation({})).toBe(false);
expect(isComponentInvocation({ tag: undefined })).toBe(false);
});

it('returns false for node with non-string tag', () => {
expect(isComponentInvocation({ tag: 123 })).toBe(false);
expect(isComponentInvocation({ tag: null })).toBe(false);
});
});
Loading