Skip to content
Draft
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
16 changes: 2 additions & 14 deletions lib/rules/template-no-arguments-for-html-elements.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const { mathmlTagNames } = require('mathml-tag-names');

const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
const { isNativeElement } = require('../utils/is-native-element');

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
Expand Down Expand Up @@ -33,15 +29,7 @@ module.exports = {

return {
GlimmerElementNode(node) {
if (!ELEMENT_TAGS.has(node.tag)) {
return;
}

// A known HTML/SVG tag can still be a component if it's bound in scope
// (block param, import, local).
const scope = sourceCode.getScope(node.parent);
const isVariable = scope.references.some((ref) => ref.identifier === node.parts[0]);
if (isVariable) {
if (!isNativeElement(node, sourceCode)) {
return;
}

Expand Down
16 changes: 2 additions & 14 deletions lib/rules/template-no-block-params-for-html-elements.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const { mathmlTagNames } = require('mathml-tag-names');

const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);
const { isNativeElement } = require('../utils/is-native-element');

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
Expand Down Expand Up @@ -33,15 +29,7 @@ module.exports = {

return {
GlimmerElementNode(node) {
if (!ELEMENT_TAGS.has(node.tag)) {
return;
}

// A known HTML/SVG tag can still be a component if it's bound in scope
// (block param, import, local).
const scope = sourceCode.getScope(node.parent);
const isVariable = scope.references.some((ref) => ref.identifier === node.parts[0]);
if (isVariable) {
if (!isNativeElement(node, sourceCode)) {
return;
}

Expand Down
34 changes: 12 additions & 22 deletions lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { isNativeElement } = require('../utils/is-native-element');

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

function isHidden(node) {
Expand All @@ -14,28 +16,12 @@ function isHidden(node) {
return false;
}

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('.')
);
}

function isTextEmpty(text) {
// Treat &nbsp; (U+00A0) and regular whitespace as empty
return text.replaceAll(/\s/g, '').replaceAll('&nbsp;', '').length === 0;
}

function hasAccessibleContent(node) {
function hasAccessibleContent(node, sourceCode) {
if (!node.children || node.children.length === 0) {
return false;
}
Expand All @@ -61,13 +47,14 @@ function hasAccessibleContent(node) {
continue;
}

// Component invocations count as content (they may render text)
if (isComponent(child)) {
// Component invocations (including custom elements and scope-bound
// identifiers) are opaque — we can't see inside, so assume content.
if (!isNativeElement(child, sourceCode)) {
return true;
}

// Recurse into non-hidden, non-component elements
if (hasAccessibleContent(child)) {
// Recurse into native HTML/SVG/MathML elements.
if (hasAccessibleContent(child, sourceCode)) {
return true;
}
}
Expand Down Expand Up @@ -110,6 +97,9 @@ module.exports = {
},
},
create(context) {
// `context.sourceCode` is the ESLint >= 8.40 shape; `context.getSourceCode()`
// covers older versions. Keep both for cross-version compatibility.
const sourceCode = context.sourceCode || context.getSourceCode();
return {
GlimmerElementNode(node) {
if (isHeadingElement(node)) {
Expand All @@ -118,7 +108,7 @@ module.exports = {
return;
}

if (!hasAccessibleContent(node)) {
if (!hasAccessibleContent(node, sourceCode)) {
context.report({ node, messageId: 'emptyHeading' });
}
}
Expand Down
16 changes: 9 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 { isNativeElement } = require('../utils/is-native-element');

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

create(context) {
// `context.sourceCode` is the ESLint >= 8.40 shape; `context.getSourceCode()`
// covers older versions. Keep both for cross-version compatibility.
const sourceCode = context.sourceCode || context.getSourceCode();
const options = context.options[0] || {};
const additionalInteractiveTags = new Set(options.additionalInteractiveTags || []);
const ignoredTags = new Set(options.ignoredTags || []);
Expand Down Expand Up @@ -179,13 +184,10 @@ 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('.')
) {
// Only analyze native HTML / SVG / MathML elements. Skip components
// (including tag names shadowed by in-scope bindings) and custom
// elements — their a11y contracts are author-defined.
if (!isNativeElement(node, sourceCode)) {
return;
}

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

const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const { mathmlTagNames } = require('mathml-tag-names');

// Authoritative set of native element tag names. Mirrors the approach
// established by #2689 (template-no-block-params-for-html-elements), which
// the maintainer requires for component-vs-element discrimination in this
// plugin. Heuristic approaches (PascalCase detection, etc.) were explicitly
// rejected there because a lowercase tag CAN be a component in GJS/GTS when
// the name is bound in scope (e.g. `const div = MyComponent; <div />`).
const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]);

/**
* Returns true if the Glimmer element node is a native HTML / SVG / MathML
* element — i.e. the tag name is in the authoritative list AND is not
* shadowed by an in-scope binding.
*
* "Native" here means **spec-registered tag name** (in the HTML, SVG, or
* MathML spec registries, reached via the `html-tags` / `svg-tags` /
* `mathml-tag-names` packages). It is NOT the same as:
*
* - "native accessibility" / "widget-ness" — see `interactive-roles.js`
* (aria-query widget taxonomy; an ARIA-tree-semantics question)
* - "native interactive content" / "focus behavior" — see
* `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model
* question about which tags can be nested inside what)
* - "natively focusable" / sequential-focus — see HTML §6.6.3
*
* This util answers only: "is this tag a first-class built-in element of one
* of the three markup-language standards, rather than a component invocation
* or a shadowed local binding?" Callers compose it with the other utils
* above when they need a more specific question (see e.g. `template-no-
* noninteractive-tabindex`, which consults both this and
* `html-interactive-content`).
*
* Returns false for:
* - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced —
* none of these tag names appear in the HTML/SVG/MathML lists)
* - custom elements (`<my-widget>`) — accepted false negative; the web-
* components namespace is open and can't be enumerated
* - scope-bound identifiers (`<div>` when `div` is a local `let` / `const` /
* import / block-param in the enclosing scope)
*
* @param {object} node - GlimmerElementNode
* @param {object} [sourceCode] - ESLint SourceCode, for scope lookup. When
* omitted, the scope check is skipped (the result is then list-based only —
* suitable for unit tests).
*/
function isNativeElement(node, sourceCode) {
if (!node || typeof node.tag !== 'string') {
return false;
}
if (!ELEMENT_TAGS.has(node.tag)) {
return false;
}
if (!sourceCode || !node.parent) {
return true;
}
const scope = sourceCode.getScope(node.parent);
const firstPart = node.parts && node.parts[0];
// Compare by identifier name rather than AST node object identity — object
// identity isn't guaranteed across parser versions (ember-eslint-parser can
// produce distinct node objects for the same token depending on how the
// scope manager walks the tree), but the resolved `.name` is stable.
if (firstPart && scope.references.some((ref) => ref.identifier?.name === firstPart?.name)) {
return false;
}
return true;
}

/**
* Inverse of {@link isNativeElement}. Returns true when the node should NOT
* be treated as a native HTML element — either because it's a component
* invocation (PascalCase, dotted, @-prefixed, this.-prefixed, custom element)
* OR a tag name that's shadowed by a scope binding.
*/
function isComponentInvocation(node, sourceCode) {
return !isNativeElement(node, sourceCode);
}

module.exports = { isNativeElement, isComponentInvocation, ELEMENT_TAGS };
6 changes: 6 additions & 0 deletions tests/lib/rules/template-no-empty-headings.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ ruleTester.run('template-no-empty-headings', rule, {
'<template><h1><this.Heading /></h1></template>',
'<template><h2><@heading /></h2></template>',
'<template><h3><ns.Heading /></h3></template>',

// Custom elements (hyphenated lowercase) aren't in the html-tags / svg-tags /
// mathml-tag-names allowlists — treated as opaque, assume content. Matches
// the accepted-false-negative convention established in #2689.
'<template><h1><my-widget /></h1></template>',
'<template><h2><x-foo>text</x-foo></h2></template>',
],
invalid: [
{
Expand Down
5 changes: 5 additions & 0 deletions tests/lib/rules/template-no-invalid-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ ruleTester.run('template-no-invalid-interactive', rule, {
code: '<template><div onclick={{action "foo"}}></div></template>',
options: [{ ignoredTags: ['div'] }],
},

// Custom elements (hyphenated lowercase) — accepted false negative per #2689.
// Their a11y contract is author-defined; ESLint can't introspect.
'<template><my-element onclick={{this.handler}}></my-element></template>',
'<template><x-foo {{on "click" this.handler}}></x-foo></template>',
],

invalid: [
Expand Down
94 changes: 94 additions & 0 deletions tests/lib/utils/is-native-element-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict';

const { isNativeElement, ELEMENT_TAGS } = require('../../../lib/utils/is-native-element');

// Tests exercise the list-lookup path only. Scope-based shadowing is covered
// by the rule-level test suites (see tests/lib/rules/template-no-block-params-
// for-html-elements.js and siblings) because it requires a real ESLint
// SourceCode / scope manager that's only built up by the rule tester.

describe('isNativeElement — list-only behavior (no sourceCode)', () => {
it('returns true for lowercase HTML tag names', () => {
expect(isNativeElement({ tag: 'div' })).toBe(true);
expect(isNativeElement({ tag: 'article' })).toBe(true);
expect(isNativeElement({ tag: 'h1' })).toBe(true);
expect(isNativeElement({ tag: 'button' })).toBe(true);
expect(isNativeElement({ tag: 'form' })).toBe(true);
expect(isNativeElement({ tag: 'section' })).toBe(true);
});

it('returns true for SVG tag names', () => {
expect(isNativeElement({ tag: 'svg' })).toBe(true);
expect(isNativeElement({ tag: 'circle' })).toBe(true);
expect(isNativeElement({ tag: 'path' })).toBe(true);
});

it('returns true for MathML tag names', () => {
expect(isNativeElement({ tag: 'mfrac' })).toBe(true);
expect(isNativeElement({ tag: 'math' })).toBe(true);
});

it('returns false for PascalCase component tags', () => {
expect(isNativeElement({ tag: 'Button' })).toBe(false);
expect(isNativeElement({ tag: 'MyWidget' })).toBe(false);
// Native-tag names in PascalCase — the core bug case.
expect(isNativeElement({ tag: 'Article' })).toBe(false);
expect(isNativeElement({ tag: 'Form' })).toBe(false);
expect(isNativeElement({ tag: 'Main' })).toBe(false);
expect(isNativeElement({ tag: 'Nav' })).toBe(false);
expect(isNativeElement({ tag: 'Section' })).toBe(false);
expect(isNativeElement({ tag: 'Table' })).toBe(false);
});

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

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

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

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

it('returns false for custom elements (accepted false negative)', () => {
// Custom elements aren't in the html-tags/svg-tags/mathml-tag-names
// allowlists. They're treated as "not a native element" so downstream
// rules skip them — matches the convention established in PR #2689.
expect(isNativeElement({ tag: 'my-element' })).toBe(false);
expect(isNativeElement({ tag: 'x-foo' })).toBe(false);
});

it('returns false for empty / missing / non-string tag', () => {
expect(isNativeElement({ tag: '' })).toBe(false);
expect(isNativeElement({ tag: undefined })).toBe(false);
expect(isNativeElement({ tag: null })).toBe(false);
expect(isNativeElement({ tag: 123 })).toBe(false);
expect(isNativeElement({})).toBe(false);
expect(isNativeElement()).toBe(false);
expect(isNativeElement(null)).toBe(false);
});
});

describe('ELEMENT_TAGS', () => {
it('includes all HTML, SVG, and MathML tag names', () => {
// Contract check — the set must be non-empty and must contain at least
// one representative tag from each of the three source packages. An exact
// size assertion would be brittle (the underlying packages add/remove tags
// across minor releases without changing their contract), so we assert the
// shape instead.
expect(ELEMENT_TAGS.size).toBeGreaterThan(0);
expect(ELEMENT_TAGS.has('div')).toBe(true);
expect(ELEMENT_TAGS.has('circle')).toBe(true);
expect(ELEMENT_TAGS.has('mfrac')).toBe(true);
});
});
Loading