Skip to content

Commit b352ee4

Browse files
authored
feat(lint): implement noAmbiguousAnchorText (#8372)
1 parent a21db74 commit b352ee4

File tree

35 files changed

+2085
-147
lines changed

35 files changed

+2085
-147
lines changed

.changeset/shy-sites-join.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`noAmbiguousAnchorText`](https://biomejs.dev/linter/rules/no-ambiguous-anchor-text/), which disallows ambiguous anchor descriptions.
6+
7+
#### Invalid
8+
9+
```html
10+
<a>learn more</a>
11+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 149 additions & 128 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ define_categories! {
164164
"lint/correctness/useValidForDirection": "https://biomejs.dev/linter/rules/use-valid-for-direction",
165165
"lint/correctness/useValidTypeof": "https://biomejs.dev/linter/rules/use-valid-typeof",
166166
"lint/correctness/useYield": "https://biomejs.dev/linter/rules/use-yield",
167+
"lint/nursery/noAmbiguousAnchorText": "https://biomejs.dev/linter/rules/no-ambiguous-anchor-text",
167168
"lint/nursery/noColorInvalidHex": "https://biomejs.dev/linter/rules/no-color-invalid-hex",
168169
"lint/nursery/noContinue": "https://biomejs.dev/linter/rules/no-continue",
169170
"lint/nursery/noDeprecatedImports": "https://biomejs.dev/linter/rules/no-deprecated-imports",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use biome_html_syntax::element_ext::AnyHtmlTagElement;
2+
3+
/// Check the element is hidden from screen reader.
4+
///
5+
/// Ref:
6+
/// - https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-hidden
7+
/// - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden
8+
/// - https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.10.0/src/util/isHiddenFromScreenReader.js
9+
pub(crate) fn is_hidden_from_screen_reader(element: &AnyHtmlTagElement) -> bool {
10+
let is_aria_hidden = element.has_truthy_attribute("aria-hidden");
11+
if is_aria_hidden {
12+
return true;
13+
}
14+
15+
match element.name_value_token().ok() {
16+
Some(name) if name.text_trimmed() == "input" => element
17+
.find_attribute_by_name("type")
18+
.and_then(|attribute| attribute.initializer()?.value().ok()?.string_value())
19+
.is_some_and(|value| value.text() == "hidden"),
20+
_ => false,
21+
}
22+
}

crates/biome_html_analyze/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#![deny(clippy::use_self)]
22

3+
mod a11y;
34
mod lint;
45
pub mod options;
56
mod registry;

crates/biome_html_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! Generated file, do not edit by hand, see `xtask/codegen`
44
55
use biome_analyze::declare_lint_group;
6+
pub mod no_ambiguous_anchor_text;
67
pub mod no_script_url;
78
pub mod no_sync_scripts;
89
pub mod no_vue_v_if_with_v_for;
@@ -14,4 +15,4 @@ pub mod use_vue_valid_v_html;
1415
pub mod use_vue_valid_v_if;
1516
pub mod use_vue_valid_v_on;
1617
pub mod use_vue_valid_v_text;
17-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_text :: UseVueValidVText ,] } }
18+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_script_url :: NoScriptUrl , self :: no_sync_scripts :: NoSyncScripts , self :: no_vue_v_if_with_v_for :: NoVueVIfWithVFor , self :: use_vue_hyphenated_attributes :: UseVueHyphenatedAttributes , self :: use_vue_valid_v_bind :: UseVueValidVBind , self :: use_vue_valid_v_else :: UseVueValidVElse , self :: use_vue_valid_v_else_if :: UseVueValidVElseIf , self :: use_vue_valid_v_html :: UseVueValidVHtml , self :: use_vue_valid_v_if :: UseVueValidVIf , self :: use_vue_valid_v_on :: UseVueValidVOn , self :: use_vue_valid_v_text :: UseVueValidVText ,] } }
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
use biome_analyze::{
2+
Ast, QueryMatch, Rule, RuleDiagnostic, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_html_syntax::{
6+
AnyHtmlContent, AnyHtmlElement, HtmlElement, HtmlOpeningElement,
7+
element_ext::AnyHtmlTagElement, inner_string_text,
8+
};
9+
use biome_rowan::AstNode;
10+
use biome_rule_options::no_ambiguous_anchor_text::NoAmbiguousAnchorTextOptions;
11+
use biome_string_case::StrOnlyExtension;
12+
13+
use crate::a11y::is_hidden_from_screen_reader;
14+
15+
declare_lint_rule! {
16+
/// Disallow ambiguous anchor descriptions.
17+
///
18+
/// Enforces <a> values are not exact matches for the phrases "click here", "here", "link", "a link", or "learn more".
19+
/// Screen readers announce tags as links/interactive, but rely on values for context.
20+
/// Ambiguous anchor descriptions do not provide sufficient context for users.
21+
///
22+
/// ## Examples
23+
///
24+
/// ### Invalid
25+
///
26+
/// ```html,expect_diagnostic
27+
/// <a>learn more</a>
28+
/// ```
29+
///
30+
/// ### Valid
31+
///
32+
/// ```html
33+
/// <a>documentation</a>
34+
/// ```
35+
///
36+
/// ## Options
37+
///
38+
/// ### `words`
39+
///
40+
/// The words option allows users to modify the strings that can be checked for in the anchor text. Useful for specifying other words in other languages.
41+
///
42+
/// Default `["click here", "here", "link", "a link", "learn more"]`
43+
///
44+
/// ```json,options
45+
/// {
46+
/// "options": {
47+
/// "words": ["click this"]
48+
/// }
49+
/// }
50+
/// ```
51+
///
52+
/// #### Invalid
53+
///
54+
/// ```html,expect_diagnostic,use_options
55+
/// <a>click this</a>
56+
/// ```
57+
///
58+
pub NoAmbiguousAnchorText {
59+
version: "next",
60+
name: "noAmbiguousAnchorText",
61+
language: "html",
62+
recommended: false,
63+
sources: &[RuleSource::EslintJsxA11y("anchor-ambiguous-text").same()],
64+
}
65+
}
66+
67+
impl Rule for NoAmbiguousAnchorText {
68+
type Query = Ast<HtmlOpeningElement>;
69+
type State = ();
70+
type Signals = Option<Self::State>;
71+
type Options = NoAmbiguousAnchorTextOptions;
72+
73+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
74+
let binding = ctx.query();
75+
let words = ctx.options().words();
76+
77+
let name = binding.name().ok()?;
78+
let value_token = name.value_token().ok()?;
79+
if value_token.text_trimmed() != "a" {
80+
return None;
81+
}
82+
83+
let parent = HtmlElement::cast(binding.syntax().parent()?)?;
84+
let text = get_accessible_child_text(&parent);
85+
86+
if words.contains(&text) {
87+
return Some(());
88+
}
89+
90+
None
91+
}
92+
93+
fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
94+
let node = ctx.query();
95+
let parent = node.syntax().parent()?;
96+
Some(
97+
RuleDiagnostic::new(
98+
rule_category!(),
99+
parent.text_range(),
100+
markup! {
101+
"No ambiguous anchor descriptions allowed."
102+
},
103+
)
104+
.note(markup! {
105+
"Ambiguous anchor descriptions do not provide sufficient context for screen reader users. Provide more context to these users."
106+
}),
107+
)
108+
}
109+
}
110+
111+
fn get_aria_label(node: &AnyHtmlTagElement) -> Option<String> {
112+
let attribute = node.attributes().find_by_name("aria-label")?;
113+
let initializer = attribute.initializer()?;
114+
let value = initializer.value().ok()?;
115+
let html_string = value.as_html_string()?;
116+
let text = html_string.inner_string_text().ok()?;
117+
118+
Some(text.to_string())
119+
}
120+
121+
fn get_img_alt(node: &AnyHtmlTagElement) -> Option<String> {
122+
let name = node.name().ok()?;
123+
let value_token = name.value_token().ok()?;
124+
if value_token.text_trimmed() != "img" {
125+
return None;
126+
}
127+
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()?;
133+
134+
Some(text.to_string())
135+
}
136+
137+
fn standardize_space_and_case(input: &str) -> String {
138+
input
139+
.chars()
140+
.filter(|c| !matches!(c, ',' | '.' | '?' | '¿' | '!' | '‽' | '¡' | ';' | ':'))
141+
.collect::<String>()
142+
.to_lowercase_cow()
143+
.split_whitespace()
144+
.collect::<Vec<_>>()
145+
.join(" ")
146+
}
147+
148+
fn get_accessible_text(node: &AnyHtmlTagElement) -> Option<String> {
149+
if is_hidden_from_screen_reader(node) {
150+
return Some(String::new());
151+
}
152+
153+
if let Some(aria_label) = get_aria_label(node) {
154+
return Some(standardize_space_and_case(&aria_label));
155+
}
156+
157+
if let Some(alt) = get_img_alt(node) {
158+
return Some(standardize_space_and_case(&alt));
159+
}
160+
161+
None
162+
}
163+
164+
fn get_accessible_child_text(node: &HtmlElement) -> String {
165+
if let Ok(opening) = node.opening_element() {
166+
let any_jsx_element: AnyHtmlTagElement = opening.clone().into();
167+
if let Some(accessible_text) = get_accessible_text(&any_jsx_element) {
168+
return accessible_text;
169+
}
170+
};
171+
172+
let raw_child_text = node
173+
.children()
174+
.into_iter()
175+
.map(|child| match child {
176+
AnyHtmlElement::AnyHtmlContent(AnyHtmlContent::HtmlContent(content)) => {
177+
if let Ok(value_token) = content.value_token() {
178+
inner_string_text(&value_token).to_string()
179+
} else {
180+
String::new()
181+
}
182+
}
183+
AnyHtmlElement::HtmlElement(element) => get_accessible_child_text(&element),
184+
AnyHtmlElement::HtmlSelfClosingElement(element) => {
185+
let any_jsx_element: AnyHtmlTagElement = element.clone().into();
186+
get_accessible_text(&any_jsx_element).unwrap_or_default()
187+
}
188+
_ => String::new(),
189+
})
190+
.collect::<Vec<String>>()
191+
.join(" ");
192+
193+
standardize_space_and_case(&raw_child_text)
194+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/* should generate diagnostics */
2+
3+
<a>here</a>
4+
5+
<a>HERE</a>
6+
7+
<a>click here</a>
8+
9+
<a>learn more</a>
10+
11+
<a>learn more</a>
12+
13+
<a>learn more.</a>
14+
15+
<a>learn more?</a>
16+
17+
<a>learn more,</a>
18+
19+
<a>learn more!</a>
20+
21+
<a>learn more;</a>
22+
23+
<a>learn more:</a>
24+
25+
<a>link</a>
26+
27+
<a>a link</a>
28+
29+
<a aria-label="click here">something</a>
30+
31+
<a> a link </a>
32+
33+
<a>a<i></i> link</a>
34+
35+
<a><i></i>a link</a>
36+
37+
<a><span>click</span> here</a>
38+
39+
<a><span> click </span> here</a>
40+
41+
<a><span aria-hidden>more text</span>learn more</a>
42+
43+
<a><span aria-hidden="true">more text</span>learn more</a>
44+
45+
<a><img alt="click here" /></a>
46+
47+
<a alt="tutorial on using eslint-plugin-jsx-a11y">click here</a>
48+
49+
<a><span alt="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>

0 commit comments

Comments
 (0)