Skip to content

feat(biome_html_analyze): port useHeadingContent a11y rule to HTML#9716

Merged
dyc3 merged 7 commits intobiomejs:nextfrom
faizkhairi:feat/html-use-heading-content
Mar 30, 2026
Merged

feat(biome_html_analyze): port useHeadingContent a11y rule to HTML#9716
dyc3 merged 7 commits intobiomejs:nextfrom
faizkhairi:feat/html-use-heading-content

Conversation

@faizkhairi
Copy link
Copy Markdown

Description

Port the useHeadingContent accessibility rule from JSX to HTML, as part of #8155.

The rule enforces that heading elements (h1-h6) have content accessible to screen readers in HTML, Vue, Svelte, and Astro files.

Checks

Flags as invalid:

  • Empty headings (<h1></h1>)
  • Whitespace-only headings (<h2> </h2>)
  • Headings with truthy aria-hidden (hidden from screen readers entirely)
  • Headings with only aria-hidden children
  • Self-closing headings (<h1 />)
  • Headings with both aria-hidden and aria-label (aria-hidden takes priority)

Allows as valid:

  • Headings with text content
  • Headings with aria-label, aria-labelledby, or title (accessible name)
  • Headings with nested visible content alongside hidden children
  • Headings containing <img> with alt text

Implementation notes

  • Case-insensitive element name matching for .html files (<H1> matches)
  • Case-sensitive matching for component frameworks (Vue, Svelte, Astro) to skip PascalCase custom components
  • Reuses shared a11y helpers (get_truthy_aria_hidden_attribute, has_accessible_name, etc.)
  • Follows the same accessible content checking pattern as useAnchorContent

Test plan

  • invalid.html: 10 test cases covering all invalid patterns
  • valid.html: 9 test cases covering all valid patterns
  • All 189 existing HTML spec tests continue to pass

Port the useHeadingContent accessibility rule from JSX to HTML.
The rule enforces that heading elements (h1-h6) have content
accessible to screen readers.

Checks:
- Empty headings (no text content)
- Headings with aria-hidden (hidden from screen readers)
- Headings with only aria-hidden children
- Self-closing headings (no content possible)
- Whitespace-only headings

Allows:
- Headings with text content
- Headings with aria-label, aria-labelledby, or title
- Headings with nested visible content
- Headings with img alt text

Handles case-insensitive matching for .html files and
case-sensitive matching for Vue/Svelte/Astro components.

Part of biomejs#8155
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 5868af7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@biomejs/biome Minor
@biomejs/cli-win32-x64 Minor
@biomejs/cli-win32-arm64 Minor
@biomejs/cli-darwin-x64 Minor
@biomejs/cli-darwin-arm64 Minor
@biomejs/cli-linux-x64 Minor
@biomejs/cli-linux-arm64 Minor
@biomejs/cli-linux-x64-musl Minor
@biomejs/cli-linux-arm64-musl Minor
@biomejs/wasm-web Minor
@biomejs/wasm-bundler Minor
@biomejs/wasm-nodejs Minor
@biomejs/backend-jsonrpc Patch
@biomejs/js-api Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added A-Linter Area: linter L-HTML Language: HTML and super languages labels Mar 30, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 30, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d667aee-8115-4bd0-807b-cd595b601dae

📥 Commits

Reviewing files that changed from the base of the PR and between c05fb68 and 5868af7.

⛔ Files ignored due to path filters (3)
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/invalid.html.snap is excluded by !**/*.snap and included by **
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/valid.html.snap is excluded by !**/*.snap and included by **
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (4)
  • crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/invalid.html
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/valid.html
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue
✅ Files skipped from review due to trivial changes (1)
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue

Walkthrough

A new HTML accessibility lint rule useHeadingContent (recommended, severity: Error) was added to detect headings (h1h6) lacking screen‑reader‑accessible content. The rule runs over HTML/JSX-derived sources, treats PascalCase tags as components in non‑HTML sources, suppresses diagnostics for elements with a truthy aria-hidden, recognises accessible names via aria-label, aria-labelledby, or title, and accepts images with non‑empty alt. It flags empty, whitespace‑only, void/self‑closing headings, and headings whose visible descendants are hidden. Test fixtures (valid/invalid) were added for HTML, Astro, Svelte, and Vue.

Suggested reviewers

  • dyc3
  • ematipico
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: porting the useHeadingContent accessibility rule from JSX to HTML, which is the primary objective of this changeset.
Description check ✅ Passed The description is well-detailed and directly related to the changeset, explaining the rule's purpose, what it flags, what it allows, implementation notes, and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 30, 2026

Merging this PR will degrade performance by 35.01%

❌ 4 regressed benchmarks
✅ 63 untouched benchmarks
⏩ 161 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
html_analyzer[real/wikipedia-JavaScript.html] 187.9 ms 289 ms -35.01%
html_analyzer[real/wikipedia-Unix.html] 165.3 ms 250.6 ms -34.03%
html_analyzer[index_1033418810622582172.html] 460.7 µs 664.3 µs -30.65%
html_analyzer[real/wikipedia-fr-Guerre_de_Canudos.html] 454.2 ms 689.5 ms -34.12%

Comparing faizkhairi:feat/html-use-heading-content (5868af7) with next (9ee64f8)

Open in CodSpeed

Footnotes

  1. 161 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Comment thread crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
@Netail Netail mentioned this pull request Mar 30, 2026
32 tasks
Add test coverage for component-based frameworks to verify:
- Case-sensitive element name matching (lowercase h1-h6 only)
- PascalCase components (H1, MyHeading) are correctly ignored
- Invalid cases (empty headings, aria-hidden) produce diagnostics

Adds 6 test files across vue/, svelte/, and astro/ subdirectories.
@faizkhairi
Copy link
Copy Markdown
Author

faizkhairi commented Mar 30, 2026

added test files for non-.html formats to verify case-sensitivity behavior and prevent regressions. For vue,svelte and Astro. All tests working and generate snaps successfully.

let me know if you need anything else 😄

@@ -0,0 +1,17 @@
<template>
<!-- should not generate diagnostics -->
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The magic comments need to go at the very top of the file

version: "next",
name: "useHeadingContent",
language: "html",
sources: &[RuleSource::EslintJsxA11y("heading-has-content").same()],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

does https://html-eslint.org/ have a similar rule? we should add it as a rule source

- Move magic comments above <template> in Vue test files (valid.vue, invalid.vue)
- Add @html-eslint/no-empty-headings as rule source for useHeadingContent
- Add eslint migration mapping for @html-eslint/no-empty-headings
- Update Vue test snapshots
@github-actions github-actions bot added the A-CLI Area: CLI label Mar 30, 2026
@faizkhairi
Copy link
Copy Markdown
Author

Ah! I found the culprit. It was the html-eslint rule is called no-empty-headings. Added it as a rule source along with the eslint migration mapping.

  • Also fixed the magic comment placement in the Vue test files.

Reference: https://html-eslint.org/docs/rules/no-empty-headings

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs`:
- Around line 156-161: The branch for AnyHtmlElement::HtmlElement only checks
children and misses paired (non-self-closing) custom components; update the
logic in the AnyHtmlElement::HtmlElement arm of has_accessible_content to mirror
the PascalCase and <Image> handling used in the self-closing branch so that
paired components (e.g., <CustomHeading></CustomHeading>) are treated as
potentially accessible. Specifically, in the AnyHtmlElement::HtmlElement case
within has_accessible_content, detect framework/custom component tags
(PascalCase or known component names like Image) before recursing into
element.children() and return true (or treat as accessible) the same way the
self-closing arm does. Apply the same change to the analogous block referenced
around lines 174-183 to ensure consistent handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9e30fe9c-000d-4404-a490-e505cdd77288

📥 Commits

Reviewing files that changed from the base of the PR and between b4ecb9b and 786e29b.

⛔ Files ignored due to path filters (3)
  • crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs is excluded by !**/migrate/eslint_any_rule_to_biome.rs and included by **
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/invalid.vue.snap is excluded by !**/*.snap and included by **
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (3)
  • crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/invalid.vue
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue
✅ Files skipped from review due to trivial changes (2)
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/invalid.vue

Comment thread crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
<h1><MyComponent></MyComponent></h1> was falsely flagged as invalid
because the HtmlElement arm only recursed into children without first
checking for PascalCase custom components. The self-closing arm already
handled this correctly.

Mirror the PascalCase guard from the HtmlSelfClosingElement arm into
the HtmlElement arm so both behave consistently.

Fixes inconsistency reported by coderabbitai.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs`:
- Around line 121-123: The helper has_accessible_content currently receives only
is_astro and therefore applies PascalCase shortcuts and "img" matching without
source-type context; change the call sites to thread an is_html boolean (e.g.,
compute is_html = source_type.is_html()) and pass that into
has_accessible_content, then update has_accessible_content to use is_html to: 1)
only apply the PascalCase/component-name shortcut when NOT is_html, and 2) only
treat "img" case-insensitively (eq_ignore_ascii_case) when is_html (otherwise
use case-sensitive comparison); do the same source-type-aware fixes for the
other element-name checks referenced around the blocks at 153-173 and 183-193.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 21803ab4-7da4-418b-8147-a853ea145bf6

📥 Commits

Reviewing files that changed from the base of the PR and between 786e29b and c05fb68.

⛔ Files ignored due to path filters (1)
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (2)
  • crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue
✅ Files skipped from review due to trivial changes (1)
  • crates/biome_html_analyze/tests/specs/a11y/useHeadingContent/vue/valid.vue

Comment thread crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
…g on source type

In plain HTML files, all tag names are case-insensitive, so PascalCase has no
special meaning — a <Span> is just <span>, not a custom component. The
PascalCase 'treat as accessible component' shortcut must only fire for
component-based frameworks (Vue, Svelte, Astro).

For the native img check: HTML is case-insensitive so eq_ignore_ascii_case is
correct there, but in component files only lowercase 'img' is the native
element. Previously, <Img /> in Vue would match eq_ignore_ascii_case('img')
before reaching the PascalCase guard, incorrectly requiring an alt attribute.

Thread is_html through has_accessible_content and gate both shortcuts on it.
Comment thread crates/biome_html_analyze/src/lint/a11y/use_heading_content.rs
@dyc3 dyc3 merged commit 701767a into biomejs:next Mar 30, 2026
16 of 17 checks passed
faizkhairi added a commit to faizkhairi/biome that referenced this pull request Mar 30, 2026
AnyHtmlElement covers text nodes, expressions, CDATA sections,
and bogus elements in addition to actual tags. This caused the rule
to fire for every content node in the document, producing a ~35%
performance regression on large HTML files.

AnyHtmlTagElement = HtmlOpeningElement | HtmlSelfClosingElement
limits the query to actual tag nodes only, which is the correct
scope for a heading rule. For HtmlOpeningElement nodes, the rule
now navigates to the parent HtmlElement via syntax().parent() to
access children and compute accessible content.

Addresses: dyc3 review feedback on PR biomejs#9716
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-CLI Area: CLI A-Linter Area: linter L-HTML Language: HTML and super languages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants