Add template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)#60
Conversation
template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)
🏎️ Benchmark Comparison
Full mitata output |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
4ca8fd8 to
c0fcecd
Compare
…default-on; skipped-levels and initial-rank opt-in)
cbf48dd to
d42922e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) | ||
| ); |
There was a problem hiding this comment.
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.
| 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'; | ||
| } |
There was a problem hiding this comment.
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.
| // Sectioning root: dialog starts fresh. | ||
| '<h1>Outer</h1><dialog><h1>Dialog</h1><h2>Section</h2></dialog>', | ||
| // role=dialog also creates a root. | ||
| '<h1>a</h1><div role="dialog"><h1>Dialog</h1></div>', | ||
| '<h1>a</h1><div role="alertdialog"><h1>Alert</h1></div>', | ||
| // Case variants: ARIA role tokens are case-insensitive per the HTML spec. | ||
| '<h1>a</h1><div role="DIALOG"><h1>Case variant</h1></div>', | ||
| '<h1>a</h1><div role="Dialog"><h1>Case variant</h1></div>', | ||
| // Role-fallback list where an INVALID token precedes `dialog` — the first | ||
| // recognised token is `dialog`, so this IS a sectioning root. | ||
| '<h1>a</h1><div role="foo dialog"><h1>Fallback dialog</h1></div>', | ||
| // No headings at all. |
There was a problem hiding this comment.
The sectioning-root logic is only exercised with plain string role="..." attributes. Adding cases for statically-resolvable mustache/concat forms (e.g. role={{"dialog"}}) and for role-fallback lists where the first valid token is an ARIA 1.3 draft role (e.g. role="comment dialog") would help prevent regressions in isSectioningRoot resolution.
Note
This is part of a series where Claude has audited
eslint-plugin-emberagainst jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate,ember-template-lint, and the HTML and WCAG specs.Summary
Add
template-heading-level: enforce a heading outline within a single template file. Default-on: at most one<h1>per sectioning root (the only check reliably verifiable within a single file in a component-based app). Opt-in:allowSkippedLevels: falseto also flag level skips,minInitialRankto set a floor on the first heading.<dialog>/role="dialog"/role="alertdialog"create nested sectioning roots that reset the count. Authoring convention, not a WCAG requirement.<h1>–<h6>) is the primary navigation affordance screen readers expose for skimming a page — JAWS, NVDA, and VoiceOver all offer heading-jump commands that rely on rank order. Authoring guides (WebAIM Semantic Structure, MDN<h1>–<h6>, A11y Project) consistently recommend a contiguous outline with no skipped ranks so heading-jump lands where users expect.html-validate'sheading-levelenforces the same convention on the same authoring-guide basis.<h1>check is reliably verifiable within a single template file, so it is the only default-on check. Skipped-level and initial-rank checks defeat themselves in component-based apps (intermediate headings live in child components and layouts the lint can't see), so both default to off and are opt-in viaallowSkippedLevels: falseandminInitialRank: 'h1'|'h2'|…. The rule as a whole remains opt-in (not added to any preset).Fix
lib/rules/template-heading-level.js; tests intests/lib/rules/template-heading-level.js(48 cases, covering default + all three opt-in configurations).{current: 0, h1Count: 0}; exiting pops. Heading visitor updates the top frame.isSectioningRoot(template-heading-level.js:32-42) recognizes<dialog>,role="dialog",role="alertdialog"— mirroring html-validate's defaults.allowMultipleH1(boolean, defaultfalse),allowSkippedLevels(boolean, defaulttrue),minInitialRank('h1' | 'h2' | ... | 'h6' | 'any', default'any'). The three defaults flip the rule from html-validate's all-on posture to Ember-friendly minimal-on: only the within-file multi-h1 check is active by default.Scope caveat — shapes the defaults
Ember apps compose pages from many templates (layouts, routes, components). The true outline spans files this rule can't see. That's not just a caveat — it's a design input that determined the defaults:
<h1>→<h3>): intermediate<h2>may live in a child component the lint can't see. Can't reliably distinguish real skip from cross-file composition. Default:allowSkippedLevels: true(off).<h1>. A route template that starts at<h2>is almost always correct, not a bug. Default:minInitialRank: 'any'(off).<h1>in same sectioning root: a within-file signal — two<h1>in one template file is suspect regardless of what ancestors render. Default: on.Teams whose templates are flat enough that cross-file tracking is reliable can opt in to the stricter checks per-scope via ESLint overrides.
Prior art
heading-has-contentheading-has-contentheading-hiddenaria-hidden/hidden.heading-level— specNo peer implements heading hierarchy; html-validate is alone in this space, and frames the rule as codifying the WebAIM/MDN authoring convention — same framing we adopt.
Flags (default config)
Flags (opt-in config)
With
allowSkippedLevels: false:With
minInitialRank: 'h1':Allows (default config)
Notes
<dialog>/role="dialog"a spec-defined "reset" role was removed from WHATWG HTML after never being implemented. The<dialog>reset matches html-validate's behavior and reflects how AT modality actually works with dialogs, but it is convention, not spec.minInitialRankat the route-template scope (via ESLint overrides) for layouts that supply the<h1>outside the file.