Skip to content

Commit 32ef472

Browse files
committed
feat(lint): implement noAmbiguousAnchorText
1 parent 3710702 commit 32ef472

File tree

19 files changed

+1181
-124
lines changed

19 files changed

+1181
-124
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_configuration/src/analyzer/linter/rules.rs

Lines changed: 144 additions & 123 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",

crates/biome_js_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_continue;
78
pub mod no_deprecated_imports;
89
pub mod no_duplicated_spread_props;
@@ -49,4 +50,4 @@ pub mod use_sorted_classes;
4950
pub mod use_spread;
5051
pub mod use_vue_define_macros_order;
5152
pub mod use_vue_multi_word_component_names;
52-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
53+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_ambiguous_anchor_text :: NoAmbiguousAnchorText , self :: no_continue :: NoContinue , self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_duplicated_spread_props :: NoDuplicatedSpreadProps , self :: no_empty_source :: NoEmptySource , self :: no_equals_to_null :: NoEqualsToNull , self :: no_floating_promises :: NoFloatingPromises , self :: no_for_in :: NoForIn , self :: no_import_cycles :: NoImportCycles , self :: no_increment_decrement :: NoIncrementDecrement , self :: no_jsx_literals :: NoJsxLiterals , self :: no_leaked_render :: NoLeakedRender , self :: no_misused_promises :: NoMisusedPromises , self :: no_multi_str :: NoMultiStr , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_parameters_only_used_in_recursion :: NoParametersOnlyUsedInRecursion , self :: no_proto :: NoProto , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_shadow :: NoShadow , self :: no_sync_scripts :: NoSyncScripts , self :: no_ternary :: NoTernary , self :: no_unknown_attribute :: NoUnknownAttribute , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: no_vue_setup_props_reactivity_loss :: NoVueSetupPropsReactivityLoss , self :: use_array_sort_compare :: UseArraySortCompare , self :: use_await_thenable :: UseAwaitThenable , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_destructuring :: UseDestructuring , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_find :: UseFind , self :: use_max_params :: UseMaxParams , self :: use_qwik_method_usage :: UseQwikMethodUsage , self :: use_qwik_valid_lexical_scope :: UseQwikValidLexicalScope , self :: use_regexp_exec :: UseRegexpExec , self :: use_sorted_classes :: UseSortedClasses , self :: use_spread :: UseSpread , self :: use_vue_define_macros_order :: UseVueDefineMacrosOrder , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
use biome_analyze::{
2+
Ast, QueryMatch, Rule, RuleDiagnostic, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_js_syntax::{
6+
AnyJsxChild, JsxElement, JsxOpeningElement, JsxSelfClosingElement, inner_string_text,
7+
jsx_ext::AnyJsxElement,
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+
/// ```jsx,expect_diagnostic
27+
/// const Invalid = () => <a>learn more</a>;
28+
/// ```
29+
///
30+
/// ### Valid
31+
///
32+
/// ```jsx
33+
/// const Valid = () => <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+
/// ```jsx,expect_diagnostic,use_options
55+
/// const Invalid = () => <a>click this</a>;
56+
/// ```
57+
///
58+
pub NoAmbiguousAnchorText {
59+
version: "next",
60+
name: "noAmbiguousAnchorText",
61+
language: "js",
62+
recommended: false,
63+
}
64+
}
65+
66+
impl Rule for NoAmbiguousAnchorText {
67+
type Query = Ast<JsxOpeningElement>;
68+
type State = ();
69+
type Signals = Option<Self::State>;
70+
type Options = NoAmbiguousAnchorTextOptions;
71+
72+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
73+
let binding = ctx.query();
74+
let words = ctx.options().words();
75+
76+
let name = binding.name().ok()?;
77+
let jsx_name = name.as_jsx_name()?;
78+
let value_token = jsx_name.value_token().ok()?;
79+
if value_token.text_trimmed() != "a" {
80+
return None;
81+
}
82+
83+
let parent = JsxElement::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: &AnyJsxElement) -> 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 text = value.as_jsx_string()?.inner_string_text().ok()?;
116+
117+
Some(text.to_string())
118+
}
119+
120+
fn get_img_alt(node: &AnyJsxElement) -> Option<String> {
121+
let name = node.name().ok()?;
122+
let jsx_name = name.as_jsx_name()?;
123+
let value_token = jsx_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 jsx_string = value.as_jsx_string()?;
132+
let text = jsx_string.inner_string_text().ok()?;
133+
134+
Some(text.to_string())
135+
}
136+
137+
fn standardize_space_and_case(input: String) -> 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_self_closing_accessible_text(node: &JsxSelfClosingElement) -> String {
149+
let any_jsx_element: AnyJsxElement = node.clone().into();
150+
if is_hidden_from_screen_reader(&any_jsx_element) {
151+
return String::new();
152+
}
153+
154+
if let Some(aria_label) = get_aria_label(&any_jsx_element) {
155+
return standardize_space_and_case(aria_label);
156+
}
157+
158+
if let Some(alt) = get_img_alt(&any_jsx_element) {
159+
return standardize_space_and_case(alt);
160+
}
161+
162+
String::new()
163+
}
164+
165+
fn get_accessible_child_text(node: &JsxElement) -> String {
166+
if let Ok(opening) = node.opening_element() {
167+
let any_jsx_element: AnyJsxElement = opening.clone().into();
168+
if is_hidden_from_screen_reader(&any_jsx_element) {
169+
return String::new();
170+
}
171+
172+
if let Some(aria_label) = get_aria_label(&any_jsx_element) {
173+
return standardize_space_and_case(aria_label);
174+
}
175+
176+
if let Some(alt) = get_img_alt(&any_jsx_element) {
177+
return standardize_space_and_case(alt);
178+
}
179+
};
180+
181+
let raw_child_text = node
182+
.children()
183+
.into_iter()
184+
.map(|child| match child {
185+
AnyJsxChild::JsxText(element) => {
186+
if let Ok(value_token) = element.value_token() {
187+
inner_string_text(&value_token).to_string()
188+
} else {
189+
String::new()
190+
}
191+
}
192+
AnyJsxChild::JsxElement(element) => get_accessible_child_text(&element),
193+
AnyJsxChild::JsxSelfClosingElement(element) => {
194+
get_self_closing_accessible_text(&element)
195+
}
196+
_ => String::new(),
197+
})
198+
.collect::<Vec<String>>()
199+
.join(" ");
200+
201+
standardize_space_and_case(raw_child_text)
202+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/* should generate diagnostics */
2+
const Invalid1 = () => {
3+
return <a>here</a>;
4+
}
5+
6+
const Invalid2 = () => {
7+
return <a>HERE</a>;
8+
}
9+
10+
const Invalid3 = () => {
11+
return <a>click here</a>;
12+
}
13+
14+
const Invalid4 = () => {
15+
return <a>learn more</a>;
16+
}
17+
18+
const Invalid5 = () => {
19+
return <a>learn more</a>;
20+
}
21+
22+
const Invalid6 = () => {
23+
return <a>learn more.</a>;
24+
}
25+
26+
const Invalid7 = () => {
27+
return <a>learn more?</a>;
28+
}
29+
30+
const Invalid8 = () => {
31+
return <a>learn more,</a>;
32+
}
33+
34+
const Invalid9 = () => {
35+
return <a>learn more!</a>;
36+
}
37+
38+
const Invalid10 = () => {
39+
return <a>learn more;</a>;
40+
}
41+
42+
const Invalid11 = () => {
43+
return <a>learn more:</a>;
44+
}
45+
46+
const Invalid12 = () => {
47+
return <a>link</a>;
48+
}
49+
50+
const Invalid13 = () => {
51+
return <a>a link</a>;
52+
}
53+
54+
const Invalid14 = () => {
55+
return <a aria-label="click here">something</a>;
56+
}
57+
58+
const Invalid15 = () => {
59+
return <a> a link </a>;
60+
}
61+
62+
const Invalid16 = () => {
63+
return <a>a<i></i> link</a>;
64+
}
65+
66+
const Invalid17 = () => {
67+
return <a><i></i>a link</a>;
68+
}
69+
70+
const Invalid18 = () => {
71+
return <a><span>click</span> here</a>;
72+
}
73+
74+
const Invalid19 = () => {
75+
return <a><span> click </span> here</a>;
76+
}
77+
78+
const Invalid20 = () => {
79+
return <a><span aria-hidden>more text</span>learn more</a>;
80+
}
81+
82+
const Invalid21 = () => {
83+
return <a><span aria-hidden="true">more text</span>learn more</a>;
84+
}
85+
86+
const Invalid22 = () => {
87+
return <a><img alt="click here" /></a>;
88+
}
89+
90+
const Invalid23 = () => {
91+
return <a alt="tutorial on using eslint-plugin-jsx-a11y">click here</a>;
92+
}
93+
94+
const Invalid24 = () => {
95+
return <a><span alt="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>;
96+
}
97+
98+
const Invalid25 = () => {
99+
return <a><CustomElement>click</CustomElement> here</a>;
100+
}

0 commit comments

Comments
 (0)