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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le

| Name                                            | Description | 💼 | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-heading-level](docs/rules/template-heading-level.md) | enforce heading hierarchy within a template | | | |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | |
Expand Down
110 changes: 110 additions & 0 deletions docs/rules/template-heading-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ember/template-heading-level

<!-- end auto-generated rule header -->

This rule codifies a heading-outline **authoring convention**, not a WCAG
requirement — no WCAG 2.1 Success Criterion prohibits skipped heading
levels. The convention is widely endorsed by WebAIM, MDN, and the A11y
Project as a screen-reader-navigation aid. Enforce if your team has
adopted it; skip otherwise.

**Defaults are tuned for Ember's component-based architecture.** Only the
one check that is reliably verifiable within a single template file runs
by default; the two checks that depend on tracking heading state across
component/route/layout boundaries are opt-in.

## Default on: multiple `<h1>` within the same sectioning root

Finding two `<h1>` in one file is a within-file signal regardless of what
ancestors render. `<dialog>` and elements with `role="dialog"` /
`role="alertdialog"` create a nested sectioning root — their `<h1>` is
validated independently.

## Opt-in: skipped levels (`allowSkippedLevels: false`)

In component-based apps the intervening headings often live in child
components the lint can't see. `<h1>` → `<h3>` at lint level might be
`<h1>` → `<MyComponent/>(renders h2)` → `<h3>` at runtime. So
skipped-level checking is off by default. Enable only if your templates
are flat enough that every heading in the rendered page appears in the
file being linted.

## Opt-in: initial-rank floor (`minInitialRank`, default `'any'`)

Layouts and parent routes commonly supply the outer `<h1>`, so child
templates naturally start at `<h2>` or deeper. The initial-rank check is
disabled by default. Set `minInitialRank: 'h1'` (strict) or `'h2'` (route
templates under a layout) to enable it at the scope you want.

## Examples

Default config:

**Forbids:**

```hbs
<h1>Title</h1>
<h1>Another title</h1>
```

**Allows** (by default — opt-in to the relevant option to flag):

```hbs
<h3>Start</h3>
{{! initial-rank check off }}
<h1>Title</h1><h3>Skipped h2</h3>
{{! skipped-levels check off }}
```

With `allowSkippedLevels: false`:

```hbs
<h1>Title</h1><h3>Skipped h2</h3> {{! now flagged }}
```

With `minInitialRank: 'h2'` (route under a layout):

```hbs
<h2>Route title</h2>
{{! allowed — satisfies min h2 }}
<h4>Too deep</h4>
{{! flagged — first heading must be h2 or stricter }}
```

## Configuration

- `allowMultipleH1` (`boolean`, default `false`): permit more than one
`<h1>` per sectioning root.
- `allowSkippedLevels` (`boolean`, default `true`): when `true`, don't
flag level skips (`<h1>` → `<h3>`). Default is `true` because
intervening headings in component-based apps often live in child
components the lint can't see.
- `minInitialRank` (`'h1' | 'h2' | ... | 'h6' | 'any'`, default `'any'`):
the coarsest heading rank allowed as the file's first heading. `'any'`
disables the check (the default, because layouts/parents supply the
outer `<h1>`). Set to `'h1'` or `'h2'` at the right scope to enable it.

```js
module.exports = {
rules: {
'ember/template-heading-level': ['error', { allowSkippedLevels: false, minInitialRank: 'h2' }],
},
};
```

## References

- [HTML spec: Headings and sections](https://html.spec.whatwg.org/multipage/sections.html#headings-and-sections)
— describes using h1–h6 for document structure. The legacy HTML outline
algorithm (which would have made sectioning elements reset heading rank)
was never implemented in browsers or AT and has been
[removed from WHATWG HTML](https://github.com/whatwg/html/pull/7829).
- [WCAG 2.1 Technique G141: Organizing a page using headings](https://www.w3.org/WAI/WCAG21/Techniques/general/G141)
— a _sufficient technique_ (one way to meet SC 1.3.1 / 2.4.6), not itself
normative. It does not prescribe no-skipped-levels.
- [WebAIM: Semantic Structure — Headings](https://webaim.org/techniques/semanticstructure/)
- [MDN: `<h1>`–`<h6>` Accessibility concerns](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements#accessibility_concerns)
- [Adrian Roselli: There Is No Document Outline Algorithm](https://adrianroselli.com/2016/08/there-is-no-document-outline-algorithm.html)
— useful context for why the sectioning-root reset behavior is a
convention, not a spec-enforced outline.
- Adapted from [`html-validate`'s `heading-level`](https://html-validate.org/rules/heading-level.html) (MIT).
164 changes: 164 additions & 0 deletions lib/rules/template-heading-level.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use strict';

// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson.
//
// Scope caveat: this rule sees ONE template file at a time. Ember apps
// compose pages from many templates; the true heading outline spans
// route/component boundaries we can't cross. So this rule validates only
// the heading order *within* this file and cannot catch cross-file jumps
// (e.g. a route template starts at <h2> because its layout supplies <h1>).
// To support layout-provided <h1>, set `minInitialRank` to a lower rank.

const { roles: ariaRoles } = require('aria-query');

const HEADING_RE = /^h([1-6])$/;

// Valid ARIA role tokens — used to find the first recognised token in a
// space-separated role-fallback list (WAI-ARIA §4.1). `presentation`/`none`
// are included in aria-query's role map. Abstract roles (e.g. `widget`,
// `structure`, `landmark`) are excluded: WAI-ARIA forbids authors from using
// abstract roles as `role` attribute values.
const VALID_ROLE_TOKENS = new Set(
[...ariaRoles.keys()].filter((name) => !ariaRoles.get(name).abstract)
);
Comment on lines +12 to +23

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VALID_ROLE_TOKENS is derived only from aria-query roles, but elsewhere in this repo template-no-invalid-role treats several WAI-ARIA 1.3 draft roles (e.g. comment, suggestion, associationlist*) as valid. This rule uses the valid-role set to resolve the first recognized token in role-fallback lists, so missing those draft roles can change behavior (e.g. role="comment dialog" would incorrectly treat dialog as the effective role and reset the sectioning root). Consider sharing/duplicating the same extended valid-role token set used by template-no-invalid-role to keep fallback resolution consistent across rules.

Copilot uses AI. Check for mistakes.

function extractLevel(tag) {
const match = HEADING_RE.exec(tag);
return match ? Number.parseInt(match[1], 10) : null;
}

function findAttr(node, name) {
return node.attributes?.find((attr) => attr.name === name);
}

function getStaticAttrString(node, name) {
const attr = findAttr(node, name);
if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') {
return null;
}
return attr.value.chars;
}

function isSectioningRoot(node) {
if (node.tag === 'dialog') {
return true;
}
const role = getStaticAttrString(node, 'role');
if (!role) {
return false;
}
// ARIA role values are case-insensitive per HTML spec, and the attribute
// holds a space-separated token list (role-fallback). Per WAI-ARIA §4.1,
// the effective role is the first token recognised by the user agent; any
// invalid-token prefix is skipped. Walk tokens in order and return true
// only when the first recognised token is dialog/alertdialog.
const tokens = role.trim().toLowerCase().split(/\s+/u);
const firstValid = tokens.find((token) => VALID_ROLE_TOKENS.has(token));
return firstValid === 'dialog' || firstValid === 'alertdialog';
}
Comment on lines +30 to +58

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStaticAttrString only treats role as static when the attribute value is a GlimmerTextNode, so cases like role={{"dialog"}} / static concat won't be recognized as sectioning roots even though the value is statically knowable (and other rules in this repo use utils/static-attr-value.getStaticAttrValue for this). Using the shared helper here would avoid false positives/negatives around dialog root resets when authors use mustache-literal or concat forms.

Copilot uses AI. Check for mistakes.

function parseMinInitialRank(value) {
if (value === 'any') {
return 6;
}
const match = /^h([1-6])$/.exec(value);
return match ? Number.parseInt(match[1], 10) : 1;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'enforce heading hierarchy within a template',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-heading-level.md',
templateMode: 'both',
},
schema: [
{
type: 'object',
properties: {
allowMultipleH1: { type: 'boolean' },
allowSkippedLevels: { type: 'boolean' },
minInitialRank: { enum: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'any'] },
},
additionalProperties: false,
},
],
messages: {
multipleH1: 'Multiple `<h1>` are not allowed',
skipped:
'Heading level can only increase by one: expected `<h{{expected}}>` but got `<h{{actual}}>`',
initial:
'Initial heading level must be `<h{{expected}}>` or higher rank but got `<h{{actual}}>`',
},
},

create(context) {
const options = context.options[0] || {};
// Only the multiple-h1 check is reliable within a single template file —
// finding two <h1> in one file is a within-file signal regardless of
// what ancestors render. The other two checks (initial rank, skipped
// levels) defeat themselves in a component-based app: layouts and child
// components supply headings the lint can't see. So both default to off.
const allowMultipleH1 = Boolean(options.allowMultipleH1);
const allowSkippedLevels =
options.allowSkippedLevels === undefined ? true : Boolean(options.allowSkippedLevels);
const minInitialRank = parseMinInitialRank(options.minInitialRank || 'any');

const stack = [{ node: null, current: 0, h1Count: 0 }];

function currentRoot() {
return stack.at(-1);
}

return {
GlimmerElementNode(node) {
const level = extractLevel(node.tag);
if (level !== null) {
const root = currentRoot();
if (level === 1 && !allowMultipleH1) {
if (root.h1Count >= 1) {
context.report({ node, messageId: 'multipleH1' });
} else {
root.h1Count += 1;
}
}
if (level <= root.current) {
root.current = level;
} else {
const expected = root.current + 1;
if (root.current === 0) {
if (level > minInitialRank) {
context.report({
node,
messageId: 'initial',
data: { expected: String(minInitialRank), actual: String(level) },
});
}
} else if (level !== expected && !allowSkippedLevels) {
context.report({
node,
messageId: 'skipped',
data: { expected: String(expected), actual: String(level) },
});
}
root.current = level;
}
}

if (isSectioningRoot(node)) {
stack.push({ node, current: 0, h1Count: 0 });
}
},

'GlimmerElementNode:exit'(node) {
const root = currentRoot();
if (root.node === node) {
stack.pop();
}
},
};
},
};
Loading
Loading