From d42922ef2d06eb34b25d564937bb5ecf59f6235c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 21:33:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20template-heading-level=20?= =?UTF-8?q?=E2=80=94=20heading=20hierarchy=20check=20(multi-h1=20default-o?= =?UTF-8?q?n;=20skipped-levels=20and=20initial-rank=20opt-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/rules/template-heading-level.md | 110 +++++++++++++ lib/rules/template-heading-level.js | 164 ++++++++++++++++++++ tests/lib/rules/template-heading-level.js | 178 ++++++++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 docs/rules/template-heading-level.md create mode 100644 lib/rules/template-heading-level.js create mode 100644 tests/lib/rules/template-heading-level.js diff --git a/README.md b/README.md index 406ed89301..24f51f2a84 100644 --- a/README.md +++ b/README.md @@ -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 | 📋 | 🔧 | | diff --git a/docs/rules/template-heading-level.md b/docs/rules/template-heading-level.md new file mode 100644 index 0000000000..212d322b87 --- /dev/null +++ b/docs/rules/template-heading-level.md @@ -0,0 +1,110 @@ +# ember/template-heading-level + + + +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 `

` within the same sectioning root + +Finding two `

` in one file is a within-file signal regardless of what +ancestors render. `` and elements with `role="dialog"` / +`role="alertdialog"` create a nested sectioning root — their `

` 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. `

` → `

` at lint level might be +`

` → `(renders h2)` → `

` 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 `

`, so child +templates naturally start at `

` 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 +

Title

+

Another title

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

Start

+{{! initial-rank check off }} +

Title

Skipped h2

+{{! skipped-levels check off }} +``` + +With `allowSkippedLevels: false`: + +```hbs +

Title

Skipped h2

{{! now flagged }} +``` + +With `minInitialRank: 'h2'` (route under a layout): + +```hbs +

Route title

+{{! allowed — satisfies min h2 }} +

Too deep

+{{! flagged — first heading must be h2 or stricter }} +``` + +## Configuration + +- `allowMultipleH1` (`boolean`, default `false`): permit more than one + `

` per sectioning root. +- `allowSkippedLevels` (`boolean`, default `true`): when `true`, don't + flag level skips (`

` → `

`). 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 `

`). 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: `

`–`

` 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). diff --git a/lib/rules/template-heading-level.js b/lib/rules/template-heading-level.js new file mode 100644 index 0000000000..a27bee285e --- /dev/null +++ b/lib/rules/template-heading-level.js @@ -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

because its layout supplies

). +// To support layout-provided

, 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) +); + +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'; +} + +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 `

` are not allowed', + skipped: + 'Heading level can only increase by one: expected `` but got ``', + initial: + 'Initial heading level must be `` or higher rank but got ``', + }, + }, + + create(context) { + const options = context.options[0] || {}; + // Only the multiple-h1 check is reliable within a single template file — + // finding two

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(); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-heading-level.js b/tests/lib/rules/template-heading-level.js new file mode 100644 index 0000000000..d43a79db91 --- /dev/null +++ b/tests/lib/rules/template-heading-level.js @@ -0,0 +1,178 @@ +const rule = require('../../../lib/rules/template-heading-level'); +const RuleTester = require('eslint').RuleTester; + +const ERR_MULTI_H1 = 'Multiple `

` are not allowed'; +const skipped = (expected, actual) => + `Heading level can only increase by one: expected \`\` but got \`\``; +const initial = (expected, actual) => + `Initial heading level must be \`\` or higher rank but got \`\``; + +const validHbs = [ + // Monotonic +1. + '

a

b

c

', + // Decrease then increase by 1. + '

a

b

c

d

e

', + // Single h1, done. + '

a

', + // Same level repeats. + '

a

b

c

', + // Decrease is fine (to h2 — second h1 would violate allowMultipleH1). + '

a

b

c

d

', + // Non-heading elements don't interfere. + '

a

text

b

', + // Sectioning root: dialog starts fresh. + '

Outer

Dialog

Section

', + // role=dialog also creates a root. + '

a

Dialog

', + '

a

Alert

', + // Case variants: ARIA role tokens are case-insensitive per the HTML spec. + '

a

Case variant

', + '

a

Case variant

', + // Role-fallback list where an INVALID token precedes `dialog` — the first + // recognised token is `dialog`, so this IS a sectioning root. + '

a

Fallback dialog

', + // No headings at all. + '

no headings

', + // Skipped levels: allowed by default (component-based intermediate + // headings often live in children the lint can't see). + '

a

b

', + '

a

b

c

', + // Initial rank: allowed by default (layout/parent often supplies

). + '

Start

', + '

Deep start

Back up

', +]; + +const invalidHbs = [ + // Default: only multiple-h1 is flagged. + { + code: '

a

b

', + output: null, + errors: [{ message: ERR_MULTI_H1 }], + }, + { + code: '

a

b

Dialog

', + output: null, + errors: [{ message: ERR_MULTI_H1 }], + }, + // `role="presentation dialog"` — `presentation` is a valid role, so the + // effective role resolved by first-valid-token semantics is `presentation`, + // NOT `dialog`. The div therefore does NOT create a fresh sectioning root, + // and its inner

is a sibling to the outer

→ multiple-h1 flagged. + { + code: '

a

Inside

', + output: null, + errors: [{ message: ERR_MULTI_H1 }], + }, +]; + +const optionSkippedLevelsStrictInvalid = [ + // With allowSkippedLevels: false the skipped-level check re-activates. + { + code: '

a

b

', + options: [{ allowSkippedLevels: false }], + output: null, + errors: [{ message: skipped(2, 3) }], + }, + { + code: '

a

b

c

', + options: [{ allowSkippedLevels: false }], + output: null, + errors: [{ message: skipped(3, 4) }], + }, +]; + +const optionMinH2Valid = [ + { code: '

Start

b

', options: [{ minInitialRank: 'h2' }] }, + { + code: '

Start

b

c

d

', + options: [{ minInitialRank: 'h2' }], + }, + { + code: '

Still ok (higher than h2)

b

', + options: [{ minInitialRank: 'h2' }], + }, +]; + +const optionMinH2Invalid = [ + { + code: '

too deep

', + options: [{ minInitialRank: 'h2' }], + output: null, + errors: [{ message: initial(2, 4) }], + }, +]; + +const optionAllowMultipleH1Valid = [ + { code: '

a

b

', options: [{ allowMultipleH1: true }] }, +]; + +const optionAllowSkippedLevelsInvalid = [ + // Default mode (skipped-levels allowed) still flags multiple-h1. + { + code: '

a

b

c

', + output: null, + errors: [{ message: ERR_MULTI_H1 }], + }, +]; + +const gjsValid = [ + ...validHbs.map((code) => ``), + ...optionMinH2Valid.map(({ code, options }) => ({ + code: ``, + options, + })), + ...optionAllowMultipleH1Valid.map(({ code, options }) => ({ + code: ``, + options, + })), +]; +const gjsInvalid = [ + ...invalidHbs.map(({ code, errors }) => ({ + code: ``, + output: null, + errors, + })), + ...optionMinH2Invalid.map(({ code, options, errors }) => ({ + code: ``, + options, + output: null, + errors, + })), + ...optionAllowSkippedLevelsInvalid.map(({ code, options, errors }) => ({ + code: ``, + ...(options ? { options } : {}), + output: null, + errors, + })), + ...optionSkippedLevelsStrictInvalid.map(({ code, options, errors }) => ({ + code: ``, + options, + output: null, + errors, + })), +]; + +const gjsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +gjsRuleTester.run('template-heading-level', rule, { + valid: gjsValid, + invalid: gjsInvalid, +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('template-heading-level', rule, { + valid: [...validHbs, ...optionMinH2Valid, ...optionAllowMultipleH1Valid], + invalid: [ + ...invalidHbs, + ...optionMinH2Invalid, + ...optionAllowSkippedLevelsInvalid, + ...optionSkippedLevelsStrictInvalid, + ], +});