-
Notifications
You must be signed in to change notification settings - Fork 0
Add template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)
#60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| 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) | ||
| ); | ||
|
|
||
| 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
|
||
|
|
||
| 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(); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
VALID_ROLE_TOKENSis derived only fromaria-queryroles, but elsewhere in this repotemplate-no-invalid-roletreats 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 treatdialogas the effective role and reset the sectioning root). Consider sharing/duplicating the same extended valid-role token set used bytemplate-no-invalid-roleto keep fallback resolution consistent across rules.