Skip to content

Commit d42922e

Browse files
committed
feat: add template-heading-level — heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)
1 parent 414d6d5 commit d42922e

4 files changed

Lines changed: 453 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
258258

259259
| Name                                            | Description | 💼 | 🔧 | 💡 |
260260
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
261+
| [template-heading-level](docs/rules/template-heading-level.md) | enforce heading hierarchy within a template | | | |
261262
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
262263
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
263264
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# ember/template-heading-level
2+
3+
<!-- end auto-generated rule header -->
4+
5+
This rule codifies a heading-outline **authoring convention**, not a WCAG
6+
requirement — no WCAG 2.1 Success Criterion prohibits skipped heading
7+
levels. The convention is widely endorsed by WebAIM, MDN, and the A11y
8+
Project as a screen-reader-navigation aid. Enforce if your team has
9+
adopted it; skip otherwise.
10+
11+
**Defaults are tuned for Ember's component-based architecture.** Only the
12+
one check that is reliably verifiable within a single template file runs
13+
by default; the two checks that depend on tracking heading state across
14+
component/route/layout boundaries are opt-in.
15+
16+
## Default on: multiple `<h1>` within the same sectioning root
17+
18+
Finding two `<h1>` in one file is a within-file signal regardless of what
19+
ancestors render. `<dialog>` and elements with `role="dialog"` /
20+
`role="alertdialog"` create a nested sectioning root — their `<h1>` is
21+
validated independently.
22+
23+
## Opt-in: skipped levels (`allowSkippedLevels: false`)
24+
25+
In component-based apps the intervening headings often live in child
26+
components the lint can't see. `<h1>``<h3>` at lint level might be
27+
`<h1>``<MyComponent/>(renders h2)``<h3>` at runtime. So
28+
skipped-level checking is off by default. Enable only if your templates
29+
are flat enough that every heading in the rendered page appears in the
30+
file being linted.
31+
32+
## Opt-in: initial-rank floor (`minInitialRank`, default `'any'`)
33+
34+
Layouts and parent routes commonly supply the outer `<h1>`, so child
35+
templates naturally start at `<h2>` or deeper. The initial-rank check is
36+
disabled by default. Set `minInitialRank: 'h1'` (strict) or `'h2'` (route
37+
templates under a layout) to enable it at the scope you want.
38+
39+
## Examples
40+
41+
Default config:
42+
43+
**Forbids:**
44+
45+
```hbs
46+
<h1>Title</h1>
47+
<h1>Another title</h1>
48+
```
49+
50+
**Allows** (by default — opt-in to the relevant option to flag):
51+
52+
```hbs
53+
<h3>Start</h3>
54+
{{! initial-rank check off }}
55+
<h1>Title</h1><h3>Skipped h2</h3>
56+
{{! skipped-levels check off }}
57+
```
58+
59+
With `allowSkippedLevels: false`:
60+
61+
```hbs
62+
<h1>Title</h1><h3>Skipped h2</h3> {{! now flagged }}
63+
```
64+
65+
With `minInitialRank: 'h2'` (route under a layout):
66+
67+
```hbs
68+
<h2>Route title</h2>
69+
{{! allowed — satisfies min h2 }}
70+
<h4>Too deep</h4>
71+
{{! flagged — first heading must be h2 or stricter }}
72+
```
73+
74+
## Configuration
75+
76+
- `allowMultipleH1` (`boolean`, default `false`): permit more than one
77+
`<h1>` per sectioning root.
78+
- `allowSkippedLevels` (`boolean`, default `true`): when `true`, don't
79+
flag level skips (`<h1>``<h3>`). Default is `true` because
80+
intervening headings in component-based apps often live in child
81+
components the lint can't see.
82+
- `minInitialRank` (`'h1' | 'h2' | ... | 'h6' | 'any'`, default `'any'`):
83+
the coarsest heading rank allowed as the file's first heading. `'any'`
84+
disables the check (the default, because layouts/parents supply the
85+
outer `<h1>`). Set to `'h1'` or `'h2'` at the right scope to enable it.
86+
87+
```js
88+
module.exports = {
89+
rules: {
90+
'ember/template-heading-level': ['error', { allowSkippedLevels: false, minInitialRank: 'h2' }],
91+
},
92+
};
93+
```
94+
95+
## References
96+
97+
- [HTML spec: Headings and sections](https://html.spec.whatwg.org/multipage/sections.html#headings-and-sections)
98+
— describes using h1–h6 for document structure. The legacy HTML outline
99+
algorithm (which would have made sectioning elements reset heading rank)
100+
was never implemented in browsers or AT and has been
101+
[removed from WHATWG HTML](https://github.com/whatwg/html/pull/7829).
102+
- [WCAG 2.1 Technique G141: Organizing a page using headings](https://www.w3.org/WAI/WCAG21/Techniques/general/G141)
103+
— a _sufficient technique_ (one way to meet SC 1.3.1 / 2.4.6), not itself
104+
normative. It does not prescribe no-skipped-levels.
105+
- [WebAIM: Semantic Structure — Headings](https://webaim.org/techniques/semanticstructure/)
106+
- [MDN: `<h1>``<h6>` Accessibility concerns](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#accessibility_concerns)
107+
- [Adrian Roselli: There Is No Document Outline Algorithm](https://adrianroselli.com/2016/08/there-is-no-document-outline-algorithm.html)
108+
— useful context for why the sectioning-root reset behavior is a
109+
convention, not a spec-enforced outline.
110+
- Adapted from [`html-validate`'s `heading-level`](https://html-validate.org/rules/heading-level.html) (MIT).
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
'use strict';
2+
3+
// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson.
4+
//
5+
// Scope caveat: this rule sees ONE template file at a time. Ember apps
6+
// compose pages from many templates; the true heading outline spans
7+
// route/component boundaries we can't cross. So this rule validates only
8+
// the heading order *within* this file and cannot catch cross-file jumps
9+
// (e.g. a route template starts at <h2> because its layout supplies <h1>).
10+
// To support layout-provided <h1>, set `minInitialRank` to a lower rank.
11+
12+
const { roles: ariaRoles } = require('aria-query');
13+
14+
const HEADING_RE = /^h([1-6])$/;
15+
16+
// Valid ARIA role tokens — used to find the first recognised token in a
17+
// space-separated role-fallback list (WAI-ARIA §4.1). `presentation`/`none`
18+
// are included in aria-query's role map. Abstract roles (e.g. `widget`,
19+
// `structure`, `landmark`) are excluded: WAI-ARIA forbids authors from using
20+
// abstract roles as `role` attribute values.
21+
const VALID_ROLE_TOKENS = new Set(
22+
[...ariaRoles.keys()].filter((name) => !ariaRoles.get(name).abstract)
23+
);
24+
25+
function extractLevel(tag) {
26+
const match = HEADING_RE.exec(tag);
27+
return match ? Number.parseInt(match[1], 10) : null;
28+
}
29+
30+
function findAttr(node, name) {
31+
return node.attributes?.find((attr) => attr.name === name);
32+
}
33+
34+
function getStaticAttrString(node, name) {
35+
const attr = findAttr(node, name);
36+
if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') {
37+
return null;
38+
}
39+
return attr.value.chars;
40+
}
41+
42+
function isSectioningRoot(node) {
43+
if (node.tag === 'dialog') {
44+
return true;
45+
}
46+
const role = getStaticAttrString(node, 'role');
47+
if (!role) {
48+
return false;
49+
}
50+
// ARIA role values are case-insensitive per HTML spec, and the attribute
51+
// holds a space-separated token list (role-fallback). Per WAI-ARIA §4.1,
52+
// the effective role is the first token recognised by the user agent; any
53+
// invalid-token prefix is skipped. Walk tokens in order and return true
54+
// only when the first recognised token is dialog/alertdialog.
55+
const tokens = role.trim().toLowerCase().split(/\s+/u);
56+
const firstValid = tokens.find((token) => VALID_ROLE_TOKENS.has(token));
57+
return firstValid === 'dialog' || firstValid === 'alertdialog';
58+
}
59+
60+
function parseMinInitialRank(value) {
61+
if (value === 'any') {
62+
return 6;
63+
}
64+
const match = /^h([1-6])$/.exec(value);
65+
return match ? Number.parseInt(match[1], 10) : 1;
66+
}
67+
68+
/** @type {import('eslint').Rule.RuleModule} */
69+
module.exports = {
70+
meta: {
71+
type: 'problem',
72+
docs: {
73+
description: 'enforce heading hierarchy within a template',
74+
category: 'Accessibility',
75+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-heading-level.md',
76+
templateMode: 'both',
77+
},
78+
schema: [
79+
{
80+
type: 'object',
81+
properties: {
82+
allowMultipleH1: { type: 'boolean' },
83+
allowSkippedLevels: { type: 'boolean' },
84+
minInitialRank: { enum: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'any'] },
85+
},
86+
additionalProperties: false,
87+
},
88+
],
89+
messages: {
90+
multipleH1: 'Multiple `<h1>` are not allowed',
91+
skipped:
92+
'Heading level can only increase by one: expected `<h{{expected}}>` but got `<h{{actual}}>`',
93+
initial:
94+
'Initial heading level must be `<h{{expected}}>` or higher rank but got `<h{{actual}}>`',
95+
},
96+
},
97+
98+
create(context) {
99+
const options = context.options[0] || {};
100+
// Only the multiple-h1 check is reliable within a single template file —
101+
// finding two <h1> in one file is a within-file signal regardless of
102+
// what ancestors render. The other two checks (initial rank, skipped
103+
// levels) defeat themselves in a component-based app: layouts and child
104+
// components supply headings the lint can't see. So both default to off.
105+
const allowMultipleH1 = Boolean(options.allowMultipleH1);
106+
const allowSkippedLevels =
107+
options.allowSkippedLevels === undefined ? true : Boolean(options.allowSkippedLevels);
108+
const minInitialRank = parseMinInitialRank(options.minInitialRank || 'any');
109+
110+
const stack = [{ node: null, current: 0, h1Count: 0 }];
111+
112+
function currentRoot() {
113+
return stack.at(-1);
114+
}
115+
116+
return {
117+
GlimmerElementNode(node) {
118+
const level = extractLevel(node.tag);
119+
if (level !== null) {
120+
const root = currentRoot();
121+
if (level === 1 && !allowMultipleH1) {
122+
if (root.h1Count >= 1) {
123+
context.report({ node, messageId: 'multipleH1' });
124+
} else {
125+
root.h1Count += 1;
126+
}
127+
}
128+
if (level <= root.current) {
129+
root.current = level;
130+
} else {
131+
const expected = root.current + 1;
132+
if (root.current === 0) {
133+
if (level > minInitialRank) {
134+
context.report({
135+
node,
136+
messageId: 'initial',
137+
data: { expected: String(minInitialRank), actual: String(level) },
138+
});
139+
}
140+
} else if (level !== expected && !allowSkippedLevels) {
141+
context.report({
142+
node,
143+
messageId: 'skipped',
144+
data: { expected: String(expected), actual: String(level) },
145+
});
146+
}
147+
root.current = level;
148+
}
149+
}
150+
151+
if (isSectioningRoot(node)) {
152+
stack.push({ node, current: 0, h1Count: 0 });
153+
}
154+
},
155+
156+
'GlimmerElementNode:exit'(node) {
157+
const root = currentRoot();
158+
if (root.node === node) {
159+
stack.pop();
160+
}
161+
},
162+
};
163+
},
164+
};

0 commit comments

Comments
 (0)