eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
129 lines (115 loc) • 3.4 kB
JavaScript
const HEADINGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
function isHidden(node) {
if (!node.attributes) {
return false;
}
if (node.attributes.some((a) => a.name === 'hidden')) {
return true;
}
const ariaHidden = node.attributes.find((a) => a.name === 'aria-hidden');
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
return true;
}
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 (U+00A0) and regular whitespace as empty
return text.replaceAll(/\s/g, '').replaceAll(' ', '').length === 0;
}
function hasAccessibleContent(node) {
if (!node.children || node.children.length === 0) {
return false;
}
for (const child of node.children) {
// Text nodes — only counts if it has real visible characters
if (child.type === 'GlimmerTextNode') {
if (!isTextEmpty(child.chars)) {
return true;
}
continue;
}
// Mustache/block statements are dynamic content
if (child.type === 'GlimmerMustacheStatement' || child.type === 'GlimmerBlockStatement') {
return true;
}
// Element nodes
if (child.type === 'GlimmerElementNode') {
// Skip hidden elements entirely
if (isHidden(child)) {
continue;
}
// Component invocations count as content (they may render text)
if (isComponent(child)) {
return true;
}
// Recurse into non-hidden, non-component elements
if (hasAccessibleContent(child)) {
return true;
}
}
}
return false;
}
function isHeadingElement(node) {
if (HEADINGS.has(node.tag)) {
return true;
}
// Also detect <div role="heading" ...>
const roleAttr = node.attributes?.find((a) => a.name === 'role');
if (roleAttr?.value?.type === 'GlimmerTextNode' && roleAttr.value.chars === 'heading') {
return true;
}
return false;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow empty heading elements',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-empty-headings.md',
},
schema: [],
messages: {
emptyHeading:
'Headings must contain accessible text content (or helper/component that provides text).',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-empty-headings.js',
docs: 'docs/rule/no-empty-headings.md',
tests: 'test/unit/rules/no-empty-headings-test.js',
},
},
create(context) {
return {
GlimmerElementNode(node) {
if (isHeadingElement(node)) {
// Skip if the heading itself is hidden
if (isHidden(node)) {
return;
}
if (!hasAccessibleContent(node)) {
context.report({ node, messageId: 'emptyHeading' });
}
}
},
};
},
};