Skip to content

Add template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)#60

Closed
johanrd wants to merge 1 commit into
masterfrom
html-validate/template-heading-level
Closed

Add template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in)#60
johanrd wants to merge 1 commit into
masterfrom
html-validate/template-heading-level

Conversation

@johanrd

@johanrd johanrd commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Note

This is part of a series where Claude has audited eslint-plugin-ember against 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: false to also flag level skips, minInitialRank to 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.

  • Premise 1: Heading rank (<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.
  • Premise 2 (honesty disclaimer): This is an authoring convention, not a WCAG requirement. No WCAG 2.1 Success Criterion prohibits skipped heading levels. SC 2.4.6 Headings and Labels is about content quality of headings and labels (do they describe topic/purpose), not hierarchy. Technique G141 is a sufficient technique for SC 1.3.1/2.4.6 — one way to conform, not itself normative. The HTML outline algorithm that would have made sectioning elements reset heading rank was removed from WHATWG HTML after never being implemented by browsers or AT. html-validate's heading-level enforces the same convention on the same authoring-guide basis.
  • Conclusion: Only the multiple-<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 via allowSkippedLevels: false and minInitialRank: 'h1'|'h2'|…. The rule as a whole remains opt-in (not added to any preset).

Fix

  • New rule lib/rules/template-heading-level.js; tests in tests/lib/rules/template-heading-level.js (48 cases, covering default + all three opt-in configurations).
  • Stack-based traversal: entering a sectioning root pushes a fresh {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.
  • Schema: allowMultipleH1 (boolean, default false), allowSkippedLevels (boolean, default true), 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:

  • Skipped levels (<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).
  • Initial rank: layouts and parent routes commonly supply the outer <h1>. A route template that starts at <h2> is almost always correct, not a bug. Default: minInitialRank: 'any' (off).
  • Multiple <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

Plugin Equivalent Verified behavior
jsx-a11y heading-has-content Different concern — checks every heading has content; does NOT check hierarchy.
vuejs-accessibility heading-has-content Same as jsx-a11y.
lit-a11y heading-hidden Different — checks headings aren't hidden via aria-hidden/hidden.
@angular-eslint/template No equivalent hierarchy rule.
html-validate heading-levelspec Full DOM-walk implementation. Our port operates within a single template; otherwise logic matches. html-validate documents the rule as convention-based, not WCAG-mandated.

No 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)

{{! Multiple h1 in one sectioning root — the default check }}
<h1>One</h1>
<h1>Two</h1>

Flags (opt-in config)

With allowSkippedLevels: false:

<h1>Title</h1>
<h3>Subsection</h3>        {{! skipped h2 }}

With minInitialRank: 'h1':

<h3>Start</h3>             {{! initial heading coarser than h1 }}

Allows (default config)

<h1>Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Another section</h2>

{{! Default: skipped levels permitted — child components may supply intermediates }}
<h1>Title</h1>
<h3>Subsection</h3>

{{! Default: initial-rank check off — layout supplies the outer h1 }}
<h2>Route title</h2>

{{! dialog creates a nested sectioning root — h1 inside is fine }}
<h1>Page title</h1>
<dialog>
  <h1>Dialog title</h1>
  <h2>Dialog section</h2>
</dialog>

Notes

  • Opt-in, framed as authoring convention: not added to any preset config. Teams that have not adopted the "no skipped heading levels, one h1 per sectioning root" convention should not be forced into it by enabling this rule.
  • Sectioning-root reset is also conventional. The HTML outline algorithm that would have given <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.
  • Configure minInitialRank at the route-template scope (via ESLint overrides) for layouts that supply the <h1> outside the file.

@johanrd johanrd changed the title Html validate/template heading level Add template-heading-level: heading hierarchy check (multi-h1 default-on; skipped-levels and initial-rank opt-in) Apr 26, 2026
@johanrd
johanrd requested a review from Copilot April 26, 2026 08:54
@github-actions

github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟢 js small 14.23 ms 13.38 ms -6.0%
🟢 js medium 6.80 ms 6.63 ms -2.5%
🟢 js large 2.69 ms 2.63 ms -2.3%
gjs small 1.10 ms 1.10 ms -0.7%
gjs medium 551.91 µs 548.67 µs -0.6%
gjs large 217.63 µs 217.14 µs -0.2%
gts small 1.09 ms 1.10 ms +0.7%
gts medium 556.22 µs 547.62 µs -1.5%
gts large 216.94 µs 217.02 µs +0.0%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~2.75 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.15.0 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            16.43 ms/iter  18.06 ms █                    
                      (11.44 ms … 32.12 ms)  27.99 ms █ ▇                  
                    (  5.65 mb …  10.30 mb)   7.28 mb █▃█▆█▃▃█▃▆▁▃▃▁▁▃▁▁▃▃▆

js small (experiment)         13.97 ms/iter  15.01 ms   █                  
                      (12.01 ms … 19.96 ms)  18.54 ms █▃█▆▆▃ ▆ ▃    ▃      
                    (  6.15 mb …   7.86 mb)   6.83 mb ████████▁██▁▄▁█▁▁▁▄▁▄

                             ┌                                            ┐
                             ╷┌────────────┬───┐                          ╷
          js small (control) ├┤            │   ├──────────────────────────┤
                             ╵└────────────┴───┘                          ╵
                               ╷┌───┬──┐        ╷
       js small (experiment)   ├┤   │  ├────────┤
                               ╵└───┴──┘        ╵
                             └                                            ┘
                             11.44 ms           19.71 ms           27.99 ms

summary
  js small (experiment)
   1.18x faster than js small (control)

------------------------------------------- -------------------------------
js medium (control)            7.47 ms/iter   7.61 ms  █                   
                       (6.24 ms … 14.67 ms)  14.25 ms ▆█▅                  
                    (  2.88 mb …   4.13 mb)   3.54 mb ███▆▃▅▂▂▄▂▁▁▂▁▁▂▂▂▁▁▂

js medium (experiment)         7.31 ms/iter   7.71 ms  █                   
                       (6.13 ms … 13.38 ms)  12.60 ms  █                   
                    (  2.59 mb …   4.45 mb)   3.54 mb ▇██▂▄▆▂▃▅▂▂▂▁▁▂▁▁▁▁▂▂

                             ┌                                            ┐
                              ╷┌────┬┐                                    ╷
         js medium (control)  ├┤    │├────────────────────────────────────┤
                              ╵└────┴┘                                    ╵
                             ╷ ┌────┬─┐                          ╷
      js medium (experiment) ├─┤    │ ├──────────────────────────┤
                             ╵ └────┴─┘                          ╵
                             └                                            ┘
                             6.13 ms           10.19 ms            14.25 ms

summary
  js medium (experiment)
   1.02x faster than js medium (control)

------------------------------------------- -------------------------------
js large (control)             3.14 ms/iter   3.01 ms  █                   
                       (2.22 ms … 10.91 ms)   8.61 ms ▄██                  
                    (372.81 kb …   3.15 mb)   1.44 mb ███▃▄▃▃▃▂▃▁▃▁▂▁▁▁▁▁▁▁

js large (experiment)          2.89 ms/iter   2.77 ms  █                   
                        (2.39 ms … 8.04 ms)   5.86 ms ▂█▆                  
                    (218.52 kb …   2.65 mb)   1.43 mb ███▄▃▃▂▂▂▂▁▂▁▂▂▁▁▂▂▁▁

                             ┌                                            ┐
                             ╷ ┌───┬                                      ╷
          js large (control) ├─┤   │──────────────────────────────────────┤
                             ╵ └───┴                                      ╵
                              ╷┌──┬                    ╷
       js large (experiment)  ├┤  │────────────────────┤
                              ╵└──┴                    ╵
                             └                                            ┘
                             2.22 ms            5.42 ms             8.61 ms

summary
  js large (experiment)
   1.09x faster than js large (control)

------------------------------------------- -------------------------------
gjs small (control)            1.22 ms/iter   1.14 ms █                    
                        (1.08 ms … 5.82 ms)   5.36 ms █                    
                    (220.76 kb …   1.66 mb)   1.06 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.27 ms/iter   1.14 ms █                    
                        (1.07 ms … 5.44 ms)   4.88 ms █                    
                    (292.84 kb …   1.72 mb)   1.06 mb █▃▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
         gjs small (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌─┬                                     ╷
      gjs small (experiment) │ │─────────────────────────────────────┤
                             └─┴                                     ╵
                             └                                            ┘
                             1.07 ms            3.22 ms             5.36 ms

summary
  gjs small (control)
   1.04x faster than gjs small (experiment)

------------------------------------------- -------------------------------
gjs medium (control)         604.97 µs/iter 560.94 µs █                    
                      (530.49 µs … 5.56 ms)   3.01 ms █                    
                    (  7.89 kb …   1.29 mb) 542.60 kb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      597.24 µs/iter 557.33 µs █                    
                      (528.37 µs … 5.55 ms)   3.20 ms █                    
                    ( 97.09 kb …   1.04 mb) 540.26 kb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                        ╷
        gjs medium (control) ││────────────────────────────────────────┤
                             └┴                                        ╵
                             ┌┬                                           ╷
     gjs medium (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             528.37 µs           1.87 ms            3.20 ms

summary
  gjs medium (experiment)
   1.01x faster than gjs medium (control)

------------------------------------------- -------------------------------
gjs large (control)          238.44 µs/iter 224.49 µs  █                   
                      (210.54 µs … 5.16 ms) 296.67 µs  █▆                  
                    ( 17.63 kb … 836.75 kb) 217.29 kb ▄██▆▇▄▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       237.72 µs/iter 223.00 µs  █                   
                      (210.69 µs … 4.70 ms) 297.75 µs  █▅                  
                    (106.02 kb … 776.56 kb) 216.42 kb ▅██▄▇▄▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌───────────┬                             ╷
         gjs large (control) ├─┤           │─────────────────────────────┤
                             ╵ └───────────┴                             ╵
                             ╷ ┌───────────┬                              ╷
      gjs large (experiment) ├─┤           │──────────────────────────────┤
                             ╵ └───────────┴                              ╵
                             └                                            ┘
                             210.54 µs         254.14 µs          297.75 µs

summary
  gjs large (experiment)
   1x faster than gjs large (control)

------------------------------------------- -------------------------------
gts small (control)            1.19 ms/iter   1.11 ms █                    
                        (1.07 ms … 6.10 ms)   5.43 ms █                    
                    (198.02 kb …   1.60 mb)   1.06 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.25 ms/iter   1.11 ms █                    
                        (1.07 ms … 6.35 ms)   5.35 ms █                    
                    (220.07 kb …   1.81 mb)   1.05 mb █▂▁▁▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
         gts small (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌─┬                                         ╷
      gts small (experiment) │ │─────────────────────────────────────────┤
                             └─┴                                         ╵
                             └                                            ┘
                             1.07 ms            3.25 ms             5.43 ms

summary
  gts small (control)
   1.05x faster than gts small (experiment)

------------------------------------------- -------------------------------
gts medium (control)         642.78 µs/iter 589.72 µs █                    
                      (532.45 µs … 5.42 ms)   2.71 ms █                    
                    (244.48 kb …   1.56 mb) 541.52 kb █▆▁▂▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      594.60 µs/iter 555.01 µs █                    
                      (529.75 µs … 5.16 ms)   1.87 ms █                    
                    ( 94.31 kb … 996.65 kb) 540.33 kb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌─┬                                          ╷
        gts medium (control) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             ┌┬                          ╷
     gts medium (experiment) ││──────────────────────────┤
                             └┴                          ╵
                             └                                            ┘
                             529.75 µs           1.62 ms            2.71 ms

summary
  gts medium (experiment)
   1.08x faster than gts medium (control)

------------------------------------------- -------------------------------
gts large (control)          239.24 µs/iter 224.09 µs  █                   
                      (210.96 µs … 4.85 ms) 281.26 µs  ██                  
                    ( 17.26 kb … 953.31 kb) 217.17 kb ▃██▅▆▆▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts large (experiment)       238.09 µs/iter 224.04 µs  █▂                  
                      (210.77 µs … 4.76 ms) 284.79 µs  ██                  
                    ( 16.91 kb … 960.63 kb) 216.44 kb ▄██▄▆▇▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌──────────────┬                         ╷
         gts large (control) ├─┤              │─────────────────────────┤
                             ╵ └──────────────┴                         ╵
                             ╷  ┌─────────────┬                           ╷
      gts large (experiment) ├──┤             │───────────────────────────┤
                             ╵  └─────────────┴                           ╵
                             └                                            ┘
                             210.77 µs         247.78 µs          284.79 µs

summary
  gts large (experiment)
   1x faster than gts large (control)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread lib/rules/template-heading-level.js Outdated
@johanrd
johanrd force-pushed the html-validate/template-heading-level branch from 4ca8fd8 to c0fcecd Compare April 27, 2026 14:01
…default-on; skipped-levels and initial-rank opt-in)
@johanrd
johanrd force-pushed the html-validate/template-heading-level branch from cbf48dd to d42922e Compare April 27, 2026 19:33
@johanrd
johanrd requested a review from Copilot April 27, 2026 19:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment on lines +12 to +23
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)
);

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.
Comment on lines +30 to +58
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';
}

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.
Comment on lines +23 to +34
// 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.

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.

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.

Copilot uses AI. Check for mistakes.
@johanrd johanrd closed this Apr 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants