-
-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathtemplate-no-empty-headings.js
More file actions
118 lines (105 loc) · 3.32 KB
/
template-no-empty-headings.js
File metadata and controls
118 lines (105 loc) · 3.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
const { isNativeElement } = require('../utils/is-native-element');
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 isTextEmpty(text) {
// Treat (U+00A0) and regular whitespace as empty
return text.replaceAll(/\s/g, '').replaceAll(' ', '').length === 0;
}
function hasAccessibleContent(node, sourceCode) {
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 (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 native HTML/SVG/MathML elements.
if (hasAccessibleContent(child, sourceCode)) {
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) {
// `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)) {
// Skip if the heading itself is hidden
if (isHidden(node)) {
return;
}
if (!hasAccessibleContent(node, sourceCode)) {
context.report({ node, messageId: 'emptyHeading' });
}
}
},
};
},
};