Skip to content

fix(html/useAltText): handle Vue dynamic :alt and v-bind:alt bindings#9355

Merged
dyc3 merged 3 commits intobiomejs:mainfrom
SchahinRohani:fix/use-alt-text-vue-dynamic-binding
Mar 6, 2026
Merged

fix(html/useAltText): handle Vue dynamic :alt and v-bind:alt bindings#9355
dyc3 merged 3 commits intobiomejs:mainfrom
SchahinRohani:fix/use-alt-text-vue-dynamic-binding

Conversation

@SchahinRohani
Copy link
Contributor

Summary

Fixes #9349

useAltText was triggering a false positive in Vue SFCs when the alt attribute is bound dynamically via :alt (Vue v-bind shorthand) or v-bind:alt (explicit form).

The root cause: :alt and v-bind:alt are not parsed as HtmlAttribute nodes but as AnyVueDirective variants (VueVBindShorthandDirective and VueDirective respectively). The previous implementation only called find_attribute_by_name("alt") which internally skips any non-HtmlAttribute nodes, so Vue directives were never matched.

The fix iterates over the full HtmlAttributeList and explicitly handles:

  • alt="..." — standard HtmlAttribute
  • :alt="..."VueVBindShorthandDirective, arg text is ":alt" (leading colon stripped)
  • v-bind:alt="..."VueDirective with name_token == "v-bind", arg text is ":alt" (leading colon stripped)

Test Plan

Tested with:

 cargo test -p biome_html_analyze

Added crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue covering all three cases.

All 132 tests pass:

test result: ok. 132 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Docs

No documentation changes needed — this is a bug fix for an existing rule, not a new rule or option.


AI Assistance Disclosure

This PR was developed with assistance from Claude (claude.ai). Claude helped debug the Biome AST structure (AnyVueDirective, VueVBindShorthandDirective, etc.) and iterate on the implementation. The final solution was reviewed and understood by me throughout the process.

@changeset-bot
Copy link

changeset-bot bot commented Mar 5, 2026

🦋 Changeset detected

Latest commit: 8cefcc2

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

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

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 5, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

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: 63be9e72-8802-4742-bcb9-d75a31b0b427

📥 Commits

Reviewing files that changed from the base of the PR and between f4b63e5 and 8cefcc2.

⛔ Files ignored due to path filters (1)
  • crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (2)
  • crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
  • crates/biome_html_syntax/src/element_ext.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs

Walkthrough

The PR updates the useAltText accessibility lint to treat Vue dynamic alt bindings as valid by changing its check to call element.find_attribute_or_vue_binding("alt"). It adds find_attribute_or_vue_binding to AnyHtmlElement to detect alt, :alt and v-bind:alt, adds a Vue test fixture (crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue) asserting no diagnostics for dynamic alt bindings, and includes a changeset referencing issue #9349.

Possibly related PRs

Suggested reviewers

  • dyc3
  • ematipico
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarises the main change: fixing the useAltText rule to handle Vue dynamic :alt and v-bind:alt bindings, directly addressing the changeset's primary objective.
Description check ✅ Passed The description is directly related to the changeset, explaining the root cause, implementation details, test plan, and how the fix addresses the false positive in Vue SFCs.
Linked Issues check ✅ Passed The PR fully addresses issue #9349 by implementing support for Vue dynamic :alt and v-bind:alt bindings in the useAltText rule, eliminating false positives and treating dynamic bindings equivalently to static alt attributes.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the useAltText rule for Vue bindings: the new find_attribute_or_vue_binding method, updated linting logic, test fixtures, and changeset documentation are all on-target.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
Contributor

@dyc3 dyc3 left a comment

Choose a reason for hiding this comment

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

  • needs a changeset
  • this isn't the first time a bug like this happened. we should add a generic function in biome_html_syntax that can extract an attribute or a vue binding based on the the attribute name its targeting.

Comment on lines +211 to +216
arg.syntax()
.text_trimmed()
.to_string()
.trim_start_matches(':')
.to_string()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

  • .to_string() allocates strings on the heap, and we shouldn't need to do that for this check. Use borrowed strings, or if you really need an owned string, use TokenText.
  • you also shouldn't need to trim :, you can simply check the static directive argument.

Copy link
Contributor Author

@SchahinRohani SchahinRohani Mar 5, 2026

Choose a reason for hiding this comment

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

No more .to_string() or : trimming, using as_vue_static_argument()name_token() directly

.to_string()
}

let is_alt = |attrs: &biome_html_syntax::HtmlAttributeList| {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this shouldn't need to be a closure

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved the logic to has_attribute_or_vue_binding on AnyHtmlElement in element_ext.rs — no more closure

@github-actions github-actions bot added the A-Parser Area: parser label Mar 5, 2026
@SchahinRohani
Copy link
Contributor Author

SchahinRohani commented Mar 5, 2026

@dyc3 added the changeset and addressed all review comments.

Should I split the has_attribute_or_vue_binding into two separate functions — one for plain HTML attributes and one for Vue bindings — or is the combined function preferred for now?

Comment on lines +131 to +135
/// Handles:
/// - `name="..."` — standard HTML attribute
/// - `:name="..."` — Vue v-bind shorthand (`VueVBindShorthandDirective`)
/// - `v-bind:name="..."` — explicit Vue v-bind (`VueDirective`)
pub fn has_attribute_or_vue_binding(&self, name_to_lookup: &str) -> bool {
Copy link
Contributor

Choose a reason for hiding this comment

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

This should return an Option<AnyHtmlAttribute> with the found attribute. It would give the function more utility.

@dyc3
Copy link
Contributor

dyc3 commented Mar 6, 2026

The combined function is preferred, thank you.

@SchahinRohani SchahinRohani force-pushed the fix/use-alt-text-vue-dynamic-binding branch from 6beb0ae to f4b63e5 Compare March 6, 2026 20:00
Copy link
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_syntax/src/element_ext.rs`:
- Around line 148-165: The matching logic in
AnyVueDirective::VueVBindShorthandDirective and the
AnyVueDirective::VueDirective branch must strip a leading ':' from the argument
token before comparing to name_to_lookup and make the v-bind name check
ASCII-case-insensitive; update the arg name extraction (used in the chains that
call name_token().ok() and text_trimmed()) to obtain the token text, remove a
leading ':' (e.g. trim_start_matches(':')) and then compare with
eq_ignore_ascii_case(name_to_lookup), and change the v-bind detection (currently
t.text_trimmed() == "v-bind") to use eq_ignore_ascii_case so both branches
correctly recognise :alt, v-bind:alt and V-BIND:alt forms.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: abe3b9aa-1746-497e-9f84-16317c72d715

📥 Commits

Reviewing files that changed from the base of the PR and between 6beb0ae and f4b63e5.

⛔ Files ignored due to path filters (1)
  • crates/biome_html_analyze/tests/specs/a11y/useAltText/vue/valid.vue.snap is excluded by !**/*.snap and included by **
📒 Files selected for processing (2)
  • crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs
  • crates/biome_html_syntax/src/element_ext.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/biome_html_analyze/src/lint/a11y/use_alt_text.rs

Comment on lines +148 to +165
AnyVueDirective::VueVBindShorthandDirective(d) => d
.arg()
.ok()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)),

// v-bind:name="..."
AnyVueDirective::VueDirective(d) => {
let is_bind = d.name_token().is_ok_and(|t| t.text_trimmed() == "v-bind");
is_bind
&& d.arg()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| {
t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Strip the leading : before matching Vue arg names.

These branches still compare the raw directive argument token against "alt". Per the parser shape described in this PR, that token is ":alt", so the helper can still miss the two Vue forms it is meant to recognise. I’d also make the v-bind check ASCII-case-insensitive for parity with normal attribute matching — false positives are a bit less funny when they survive the fix.

Proposed fix
                     AnyVueDirective::VueVBindShorthandDirective(d) => d
                         .arg()
                         .ok()
                         .and_then(|arg| arg.arg().ok())
                         .and_then(|arg| arg.as_vue_static_argument().cloned())
                         .and_then(|s| s.name_token().ok())
-                        .is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)),
+                        .is_some_and(|t| {
+                            let name = t.text_trimmed();
+                            name.strip_prefix(':')
+                                .unwrap_or(name)
+                                .eq_ignore_ascii_case(name_to_lookup)
+                        }),
 
                     // v-bind:name="..."
                     AnyVueDirective::VueDirective(d) => {
-                        let is_bind = d.name_token().is_ok_and(|t| t.text_trimmed() == "v-bind");
+                        let is_bind = d
+                            .name_token()
+                            .is_ok_and(|t| t.text_trimmed().eq_ignore_ascii_case("v-bind"));
                         is_bind
                             && d.arg()
                                 .and_then(|arg| arg.arg().ok())
                                 .and_then(|arg| arg.as_vue_static_argument().cloned())
                                 .and_then(|s| s.name_token().ok())
                                 .is_some_and(|t| {
-                                    t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)
+                                    let name = t.text_trimmed();
+                                    name.strip_prefix(':')
+                                        .unwrap_or(name)
+                                        .eq_ignore_ascii_case(name_to_lookup)
                                 })
                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
AnyVueDirective::VueVBindShorthandDirective(d) => d
.arg()
.ok()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)),
// v-bind:name="..."
AnyVueDirective::VueDirective(d) => {
let is_bind = d.name_token().is_ok_and(|t| t.text_trimmed() == "v-bind");
is_bind
&& d.arg()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| {
t.text_trimmed().eq_ignore_ascii_case(name_to_lookup)
AnyVueDirective::VueVBindShorthandDirective(d) => d
.arg()
.ok()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| {
let name = t.text_trimmed();
name.strip_prefix(':')
.unwrap_or(name)
.eq_ignore_ascii_case(name_to_lookup)
}),
// v-bind:name="..."
AnyVueDirective::VueDirective(d) => {
let is_bind = d
.name_token()
.is_ok_and(|t| t.text_trimmed().eq_ignore_ascii_case("v-bind"));
is_bind
&& d.arg()
.and_then(|arg| arg.arg().ok())
.and_then(|arg| arg.as_vue_static_argument().cloned())
.and_then(|s| s.name_token().ok())
.is_some_and(|t| {
let name = t.text_trimmed();
name.strip_prefix(':')
.unwrap_or(name)
.eq_ignore_ascii_case(name_to_lookup)
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_html_syntax/src/element_ext.rs` around lines 148 - 165, The
matching logic in AnyVueDirective::VueVBindShorthandDirective and the
AnyVueDirective::VueDirective branch must strip a leading ':' from the argument
token before comparing to name_to_lookup and make the v-bind name check
ASCII-case-insensitive; update the arg name extraction (used in the chains that
call name_token().ok() and text_trimmed()) to obtain the token text, remove a
leading ':' (e.g. trim_start_matches(':')) and then compare with
eq_ignore_ascii_case(name_to_lookup), and change the v-bind detection (currently
t.text_trimmed() == "v-bind") to use eq_ignore_ascii_case so both branches
correctly recognise :alt, v-bind:alt and V-BIND:alt forms.

Copy link
Contributor

@dyc3 dyc3 left a comment

Choose a reason for hiding this comment

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

Thank you!

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 6, 2026

Merging this PR will not alter performance

✅ 64 untouched benchmarks
⏩ 152 skipped benchmarks1


Comparing SchahinRohani:fix/use-alt-text-vue-dynamic-binding (8cefcc2) with main (5046d2b)

Open in CodSpeed

Footnotes

  1. 152 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.

@SchahinRohani
Copy link
Contributor Author

You are welcome!

CodeRabbit had a good catch for the v-bind case-insensitivity, so fixing that:

- let is_bind = d.name_token().is_ok_and(|t| t.text_trimmed() == "v-bind");
+ let is_bind = d.name_token().is_ok_and(|t| t.text_trimmed().eq_ignore_ascii_case("v-bind"));

@SchahinRohani SchahinRohani force-pushed the fix/use-alt-text-vue-dynamic-binding branch from f4b63e5 to 7d5e401 Compare March 6, 2026 20:20
@SchahinRohani SchahinRohani force-pushed the fix/use-alt-text-vue-dynamic-binding branch from 7d5e401 to 8cefcc2 Compare March 6, 2026 20:20
@dyc3 dyc3 merged commit 78e74a2 into biomejs:main Mar 6, 2026
18 checks passed
@github-actions github-actions bot mentioned this pull request Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 lint/a11y/useAltText: False positive for Vue dynamic :alt binding in .vue files

2 participants