Skip to content

Commit ae31a00

Browse files
authored
fix(html_analyze): correctly handle dynamic values for attributes (#10683)
1 parent a4cc4ab commit ae31a00

269 files changed

Lines changed: 5445 additions & 1077 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/shiny-mugs-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#10657](https://github.com/biomejs/biome/issues/10657) [#10671](https://github.com/biomejs/biome/issues/10671) [#10661](https://github.com/biomejs/biome/issues/10661) [#10637](https://github.com/biomejs/biome/issues/10637) [#10718](https://github.com/biomejs/biome/issues/10718): HTML rules now correctly handle dynamic attributes.

crates/biome_html_analyze/src/a11y.rs

Lines changed: 102 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
1010
use biome_aria::event_handlers::matches_event_handler;
1111
use biome_html_syntax::element_ext::AnyHtmlTagElement;
12-
use biome_html_syntax::{AnyHtmlAttribute, AnyVueDirective, HtmlAttribute};
12+
use biome_html_syntax::{AnyHtmlAttribute, AnyVueDirective};
1313
use biome_rowan::AstNodeList;
1414

1515
// ============================================================================
@@ -23,38 +23,52 @@ use biome_rowan::AstNodeList;
2323
/// Missing values, empty strings, and whitespace-only values are considered falsy.
2424
///
2525
/// Ref: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden>
26-
fn is_aria_hidden_value_truthy(attribute: &HtmlAttribute) -> bool {
27-
attribute.value().is_some_and(|value| {
28-
let trimmed = value.trim();
26+
fn is_aria_hidden_value_truthy(attribute: &AnyHtmlAttribute) -> bool {
27+
attribute.as_static_value().is_some_and(|value| {
28+
let trimmed = value.text().trim();
2929
!trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("false")
3030
})
3131
}
3232

3333
/// Checks if an attribute value equals `"true"` exactly (case-sensitive).
34-
fn is_strict_true_value(attribute: &HtmlAttribute) -> bool {
35-
attribute.value().is_some_and(|value| value == "true")
34+
fn is_strict_true_value(attribute: &AnyHtmlAttribute) -> bool {
35+
attribute
36+
.as_static_value()
37+
.is_some_and(|value| value.text() == "true")
3638
}
3739

3840
/// Checks if an attribute has a non-empty value after trimming whitespace.
3941
///
4042
/// Returns `false` for attributes with no value, empty strings, or whitespace-only values.
41-
fn has_non_empty_value(attribute: &HtmlAttribute) -> bool {
42-
attribute
43-
.value()
44-
.is_some_and(|value| !value.trim().is_empty())
43+
fn has_non_empty_value(attribute: &AnyHtmlAttribute) -> bool {
44+
match attribute {
45+
AnyHtmlAttribute::HtmlAttribute(html_attribute) => {
46+
if html_attribute.initializer().is_none() {
47+
return false;
48+
}
49+
if let Some(static_value) = html_attribute.as_static_value()
50+
&& let Some(static_value) = static_value.as_string_constant()
51+
{
52+
return !static_value.trim().is_empty();
53+
}
54+
true
55+
}
56+
// Vue directives always have a bound expression, so always non-empty
57+
_ => true,
58+
}
4559
}
4660

4761
/// Checks if an attribute value matches the expected string (case-insensitive).
4862
///
4963
/// Useful for checking HTML attribute values like `type="hidden"` or `role="button"`
5064
/// where the comparison should be case-insensitive per HTML spec.
5165
pub(crate) fn attribute_value_equals_ignore_case(
52-
attribute: &HtmlAttribute,
66+
attribute: &AnyHtmlAttribute,
5367
expected: &str,
5468
) -> bool {
5569
attribute
56-
.value()
57-
.is_some_and(|value| value.eq_ignore_ascii_case(expected))
70+
.as_static_value()
71+
.is_some_and(|value| value.text().eq_ignore_ascii_case(expected))
5872
}
5973

6074
// ============================================================================
@@ -72,17 +86,22 @@ pub(crate) fn attribute_value_equals_ignore_case(
7286
/// - <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden>
7387
/// - <https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js>
7488
pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool {
75-
if element
76-
.find_attribute_by_name("aria-hidden")
77-
.is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
89+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-hidden")
90+
&& is_aria_hidden_value_truthy(&attr)
7891
{
7992
return true;
8093
}
8194

8295
match element.name_value_token() {
83-
Some(name) if name.text_trimmed() == "input" => element
84-
.find_attribute_by_name("type")
85-
.is_some_and(|attr| attribute_value_equals_ignore_case(&attr, "hidden")),
96+
Some(name) if name.text_trimmed() == "input" => {
97+
if let Some(attr) = element.find_attribute_or_vue_binding("type")
98+
&& attribute_value_equals_ignore_case(&attr, "hidden")
99+
{
100+
return true;
101+
}
102+
103+
false
104+
}
86105
_ => false,
87106
}
88107
}
@@ -93,9 +112,13 @@ pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool
93112
///
94113
/// Ref: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden>
95114
pub(crate) fn is_aria_hidden_true(element: &AnyHtmlTagElement) -> bool {
96-
element
97-
.find_attribute_by_name("aria-hidden")
98-
.is_some_and(|attr| is_strict_true_value(&attr))
115+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-hidden")
116+
&& is_strict_true_value(&attr)
117+
{
118+
return true;
119+
}
120+
121+
false
99122
}
100123

101124
/// Returns the `aria-hidden` attribute if it has a truthy value.
@@ -106,8 +129,8 @@ pub(crate) fn is_aria_hidden_true(element: &AnyHtmlTagElement) -> bool {
106129
/// Ref: <https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden>
107130
pub(crate) fn get_truthy_aria_hidden_attribute(
108131
element: &AnyHtmlTagElement,
109-
) -> Option<HtmlAttribute> {
110-
let attribute = element.find_attribute_by_name("aria-hidden")?;
132+
) -> Option<AnyHtmlAttribute> {
133+
let attribute = element.find_attribute_or_vue_binding("aria-hidden")?;
111134
if is_aria_hidden_value_truthy(&attribute) {
112135
Some(attribute)
113136
} else {
@@ -119,9 +142,14 @@ pub(crate) fn get_truthy_aria_hidden_attribute(
119142
///
120143
/// Whitespace-only values are considered empty.
121144
pub(crate) fn has_non_empty_attribute(element: &AnyHtmlTagElement, name: &str) -> bool {
122-
element
123-
.find_attribute_by_name(name)
124-
.is_some_and(|attr| has_non_empty_value(&attr))
145+
let Some(attr) = element.find_attribute_or_vue_binding(name) else {
146+
return false;
147+
};
148+
149+
match attr {
150+
AnyHtmlAttribute::HtmlAttribute(_) => has_non_empty_value(&attr),
151+
_ => true,
152+
}
125153
}
126154

127155
/// Returns `true` if the element has all of the named attributes with non-empty values.
@@ -163,9 +191,13 @@ pub(crate) fn has_accessible_name(element: &AnyHtmlTagElement) -> bool {
163191
pub(crate) fn html_element_has_truthy_aria_hidden(
164192
element: &biome_html_syntax::HtmlElement,
165193
) -> bool {
166-
element
167-
.find_attribute_by_name("aria-hidden")
168-
.is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
194+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-hidden")
195+
&& is_aria_hidden_value_truthy(&attr)
196+
{
197+
return true;
198+
}
199+
200+
false
169201
}
170202

171203
/// Checks if an [`HtmlSelfClosingElement`] has a truthy `aria-hidden` attribute.
@@ -174,9 +206,13 @@ pub(crate) fn html_element_has_truthy_aria_hidden(
174206
pub(crate) fn html_self_closing_element_has_truthy_aria_hidden(
175207
element: &biome_html_syntax::HtmlSelfClosingElement,
176208
) -> bool {
177-
element
178-
.find_attribute_by_name("aria-hidden")
179-
.is_some_and(|attr| is_aria_hidden_value_truthy(&attr))
209+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-hidden")
210+
&& is_aria_hidden_value_truthy(&attr)
211+
{
212+
return true;
213+
}
214+
215+
false
180216
}
181217

182218
/// Checks if an [`HtmlSelfClosingElement`] has an accessible name via `aria-label`,
@@ -186,16 +222,25 @@ pub(crate) fn html_self_closing_element_has_truthy_aria_hidden(
186222
pub(crate) fn html_self_closing_element_has_accessible_name(
187223
element: &biome_html_syntax::HtmlSelfClosingElement,
188224
) -> bool {
189-
let has_aria_label = element
190-
.find_attribute_by_name("aria-label")
191-
.is_some_and(|attr| has_non_empty_value(&attr));
192-
let has_aria_labelledby = element
193-
.find_attribute_by_name("aria-labelledby")
194-
.is_some_and(|attr| has_non_empty_value(&attr));
195-
let has_title = element
196-
.find_attribute_by_name("title")
197-
.is_some_and(|attr| has_non_empty_value(&attr));
198-
has_aria_label || has_aria_labelledby || has_title
225+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-label")
226+
&& has_non_empty_value(&attr)
227+
{
228+
return true;
229+
}
230+
231+
if let Some(attr) = element.find_attribute_or_vue_binding("aria-labelledby")
232+
&& has_non_empty_value(&attr)
233+
{
234+
return true;
235+
}
236+
237+
if let Some(attr) = element.find_attribute_or_vue_binding("title")
238+
&& has_non_empty_value(&attr)
239+
{
240+
return true;
241+
}
242+
243+
false
199244
}
200245

201246
/// Checks if an [`HtmlSelfClosingElement`] has the named attribute with a non-empty value.
@@ -205,9 +250,13 @@ pub(crate) fn html_self_closing_element_has_non_empty_attribute(
205250
element: &biome_html_syntax::HtmlSelfClosingElement,
206251
name: &str,
207252
) -> bool {
208-
element
209-
.find_attribute_by_name(name)
210-
.is_some_and(|attr| has_non_empty_value(&attr))
253+
if let Some(attr) = element.find_attribute_or_vue_binding(name)
254+
&& has_non_empty_value(&attr)
255+
{
256+
return true;
257+
}
258+
259+
false
211260
}
212261

213262
/// Check if the element is `contentEditable`
@@ -216,9 +265,13 @@ pub(crate) fn html_self_closing_element_has_non_empty_attribute(
216265
/// - https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable
217266
/// - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isContentEditable.js
218267
pub(crate) fn is_content_editable(element: &AnyHtmlTagElement) -> bool {
219-
element
220-
.find_attribute_by_name("contenteditable")
221-
.is_some_and(|attribute| is_strict_true_value(&attribute))
268+
if let Some(attr) = element.find_attribute_or_vue_binding("contenteditable")
269+
&& is_strict_true_value(&attr)
270+
{
271+
return true;
272+
}
273+
274+
false
222275
}
223276

224277
/// Check if the element contains event handler

crates/biome_html_analyze/src/lint/a11y/no_access_key.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use biome_analyze::{
33
};
44
use biome_console::markup;
55
use biome_diagnostics::Severity;
6-
use biome_html_syntax::HtmlAttribute;
6+
use biome_html_syntax::AnyHtmlAttribute;
77
use biome_rowan::{AstNode, BatchMutationExt};
88
use biome_rule_options::no_access_key::NoAccessKeyOptions;
99

@@ -50,14 +50,15 @@ declare_lint_rule! {
5050
}
5151

5252
impl Rule for NoAccessKey {
53-
type Query = Ast<HtmlAttribute>;
53+
type Query = Ast<AnyHtmlAttribute>;
5454
type State = ();
5555
type Signals = Option<Self::State>;
5656
type Options = NoAccessKeyOptions;
5757

5858
fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
5959
let node = ctx.query();
60-
if is_accesskey_attribute(node) {
60+
61+
if node.is_attribute_or_vue_binding("accesskey") {
6162
return Some(());
6263
}
6364

@@ -95,10 +96,3 @@ impl Rule for NoAccessKey {
9596
))
9697
}
9798
}
98-
99-
fn is_accesskey_attribute(node: &HtmlAttribute) -> bool {
100-
node.name().is_ok_and(|name| {
101-
name.value_token()
102-
.is_ok_and(|value_token| value_token.text_trimmed() == "accesskey")
103-
})
104-
}

crates/biome_html_analyze/src/lint/a11y/no_ambiguous_anchor_text.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,27 +111,23 @@ impl Rule for NoAmbiguousAnchorText {
111111
}
112112

113113
fn get_aria_label(node: &AnyHtmlTagElement) -> Option<String> {
114-
let attribute = node.attributes().find_by_name("aria-label")?;
115-
let initializer = attribute.initializer()?;
116-
let value = initializer.value().ok()?;
117-
let html_string = value.as_html_string()?;
118-
let text = html_string.inner_string_text().ok()?;
114+
let attribute = node
115+
.attributes()
116+
.find_attribute_or_vue_binding("aria-label")?;
117+
let attribute_value = attribute.as_static_value()?;
119118

120-
Some(text.to_string())
119+
Some(attribute_value.text().to_string())
121120
}
122121

123122
fn get_img_alt(node: &AnyHtmlTagElement, source_type: &HtmlFileSource) -> Option<String> {
124123
if !is_html_tag(node, source_type, "img") {
125124
return None;
126125
}
127126

128-
let attribute = node.attributes().find_by_name("alt")?;
129-
let initializer = attribute.initializer()?;
130-
let value = initializer.value().ok()?;
131-
let html_string = value.as_html_string()?;
132-
let text = html_string.inner_string_text().ok()?;
127+
let attribute = node.attributes().find_attribute_or_vue_binding("alt")?;
128+
let attribute_value = attribute.as_static_value()?;
133129

134-
Some(text.to_string())
130+
Some(attribute_value.text().to_string())
135131
}
136132

137133
fn standardize_space_and_case(input: &str) -> String {

0 commit comments

Comments
 (0)