mirror of
https://github.com/danbulant/oxc
synced 2026-05-19 04:08:41 +00:00
feat(eslint/jsx_a11y): implement anchor_ambiguous_text (#5729)
Co-authored-by: Cameron Clark <cameron.clark@hey.com>
This commit is contained in:
parent
111d722fe1
commit
7236d14dc8
3 changed files with 486 additions and 0 deletions
|
|
@ -379,6 +379,7 @@ mod unicorn {
|
|||
|
||||
mod jsx_a11y {
|
||||
pub mod alt_text;
|
||||
pub mod anchor_ambiguous_text;
|
||||
pub mod anchor_has_content;
|
||||
pub mod anchor_is_valid;
|
||||
pub mod aria_activedescendant_has_tabindex;
|
||||
|
|
@ -741,6 +742,7 @@ oxc_macros::declare_all_lint_rules! {
|
|||
jsx_a11y::role_supports_aria_props,
|
||||
jsx_a11y::scope,
|
||||
jsx_a11y::tabindex_no_positive,
|
||||
jsx_a11y::anchor_ambiguous_text,
|
||||
nextjs::google_font_display,
|
||||
nextjs::google_font_preconnect,
|
||||
nextjs::inline_script_id,
|
||||
|
|
|
|||
314
crates/oxc_linter/src/rules/jsx_a11y/anchor_ambiguous_text.rs
Normal file
314
crates/oxc_linter/src/rules/jsx_a11y/anchor_ambiguous_text.rs
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use oxc_ast::{
|
||||
ast::{JSXChild, JSXElement},
|
||||
AstKind,
|
||||
};
|
||||
use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_macros::declare_oxc_lint;
|
||||
use oxc_span::{CompactStr, Span};
|
||||
|
||||
use crate::{
|
||||
context::LintContext,
|
||||
rule::Rule,
|
||||
utils::{
|
||||
get_element_type, get_string_literal_prop_value, has_jsx_prop_ignore_case,
|
||||
is_hidden_from_screen_reader,
|
||||
},
|
||||
AstNode,
|
||||
};
|
||||
|
||||
fn anchor_has_ambiguous_text(span: Span) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn("Unexpected ambagious anchor link text.").with_label(span)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct AnchorAmbiguousText(Box<AnchorAmbiguousTextConfig>);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnchorAmbiguousTextConfig {
|
||||
words: Vec<CompactStr>,
|
||||
}
|
||||
|
||||
impl Default for AnchorAmbiguousTextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
words: vec![
|
||||
CompactStr::new("click here"),
|
||||
CompactStr::new("here"),
|
||||
CompactStr::new("link"),
|
||||
CompactStr::new("a link"),
|
||||
CompactStr::new("learn more"),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for AnchorAmbiguousText {
|
||||
type Target = AnchorAmbiguousTextConfig;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
declare_oxc_lint!(
|
||||
/// ### What it does
|
||||
///
|
||||
/// Inspects anchor link text for the use of ambiguous words.
|
||||
///
|
||||
/// This rule checks the text from the anchor element `aria-label` if available.
|
||||
/// In absence of an anchor `aria-label` it combines the following text of it's children:
|
||||
/// * `aria-label` if available
|
||||
/// * if the child is an image, the `alt` text
|
||||
/// * the text content of the HTML element
|
||||
///
|
||||
/// ### Why is this bad?
|
||||
///
|
||||
/// Screen readers users rely on link text for context, ambiguous words such as "click here" do
|
||||
/// not provide enough context.
|
||||
///
|
||||
/// ### Examples
|
||||
///
|
||||
/// Examples of **incorrect** code for this rule:
|
||||
/// ```jsx
|
||||
/// <a>link</a>
|
||||
/// <a>click here</a>
|
||||
/// ```
|
||||
///
|
||||
/// Examples of **correct** code for this rule:
|
||||
/// ```jsx
|
||||
/// <a>read this tutorial</a>
|
||||
/// <a aria-label="oxc linter documentation">click here</a>
|
||||
/// ```
|
||||
AnchorAmbiguousText,
|
||||
restriction,
|
||||
);
|
||||
|
||||
impl Rule for AnchorAmbiguousText {
|
||||
fn from_configuration(value: serde_json::Value) -> Self {
|
||||
let mut config = AnchorAmbiguousTextConfig::default();
|
||||
|
||||
if let Some(words_array) =
|
||||
value.get(0).and_then(|v| v.get("words")).and_then(serde_json::Value::as_array)
|
||||
{
|
||||
config.words = words_array
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(CompactStr::from)
|
||||
.collect();
|
||||
}
|
||||
|
||||
Self(Box::new(config))
|
||||
}
|
||||
|
||||
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
|
||||
let AstKind::JSXElement(jsx_el) = node.kind() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(name) = get_element_type(ctx, &jsx_el.opening_element) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if name != "a" {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(text) = get_accessible_text(jsx_el, ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if text.trim() == "" {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.words.contains(&normalize_str(&text)) {
|
||||
ctx.diagnostic(anchor_has_ambiguous_text(jsx_el.span));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/65c9338c62c558d3c1c2dbf5ecc55cf04dbfe80c/src/util/getAccessibleChildText.js#L15
|
||||
fn normalize_str(text: &str) -> CompactStr {
|
||||
// `to_lowercase` is disallowed. however we need to remove certain chars later which requires converting to a String
|
||||
// the overhead of going &str -> cow string -> string is greater than just using to_lowercase
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
let mut normalized_str = text.to_lowercase();
|
||||
normalized_str.retain(|c| {
|
||||
c != ','
|
||||
&& c != '.'
|
||||
&& c != '?'
|
||||
&& c != '¿'
|
||||
&& c != '!'
|
||||
&& c != '‽'
|
||||
&& c != '¡'
|
||||
&& c != ';'
|
||||
&& c != ':'
|
||||
});
|
||||
|
||||
if normalized_str.contains(char::is_whitespace) {
|
||||
let parts: Vec<String> =
|
||||
normalized_str.split_whitespace().map(std::string::ToString::to_string).collect();
|
||||
return CompactStr::from(parts.join(" "));
|
||||
}
|
||||
|
||||
CompactStr::from(normalized_str)
|
||||
}
|
||||
|
||||
// https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/65c9338c62c558d3c1c2dbf5ecc55cf04dbfe80c/src/util/getAccessibleChildText.js#L31
|
||||
fn get_accessible_text<'a, 'b>(
|
||||
jsx_el: &'b JSXElement<'a>,
|
||||
ctx: &LintContext<'a>,
|
||||
) -> Option<Cow<'b, str>> {
|
||||
if let Some(aria_label) = has_jsx_prop_ignore_case(&jsx_el.opening_element, "aria-label") {
|
||||
if let Some(label_text) = get_string_literal_prop_value(aria_label) {
|
||||
return Some(Cow::Borrowed(label_text));
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(name) = get_element_type(ctx, &jsx_el.opening_element) {
|
||||
if name == "img" {
|
||||
if let Some(alt_text) = has_jsx_prop_ignore_case(&jsx_el.opening_element, "alt") {
|
||||
if let Some(text) = get_string_literal_prop_value(alt_text) {
|
||||
return Some(Cow::Borrowed(text));
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if is_hidden_from_screen_reader(ctx, &jsx_el.opening_element) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text: Vec<Cow<'b, str>> = jsx_el
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|child| match child {
|
||||
JSXChild::Element(child_el) => get_accessible_text(child_el, ctx),
|
||||
JSXChild::Text(text_el) => Some(Cow::Borrowed(text_el.value.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(Cow::Owned(text.join(" ")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
use crate::tester::Tester;
|
||||
|
||||
let pass = vec![
|
||||
(
|
||||
r#"<a><Image alt="documentation" /></a>;"#,
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Image": "img" } } } }),
|
||||
),
|
||||
),
|
||||
("<a>documentation</a>;", None, None),
|
||||
("<a>${here}</a>;", None, None),
|
||||
(r#"<a aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</a>;"#, None, None),
|
||||
(
|
||||
r#"<a><span aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>;"#,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(r#"<a><img alt="documentation" /></a>;"#, None, None),
|
||||
(
|
||||
"<a>click here</a>",
|
||||
Some(serde_json::json!([{ "words": ["disabling the defaults"], }])),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"<Link>documentation</Link>;",
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Link": "a" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
r#"<a><Image alt="documentation" /></a>;"#,
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Image": "img" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
"<Link>${here}</Link>;",
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Link": "a" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
r#"<Link aria-label="tutorial on using eslint-plugin-jsx-a11y">click here</Link>;"#,
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Link": "a" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
"<Link>click here</Link>",
|
||||
Some(
|
||||
serde_json::json!([{ "words": ["disabling the defaults with components"], }]),
|
||||
),
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Link": "a" } } } }),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
("<a>here</a>;", None, None),
|
||||
("<a>HERE</a>;", None, None),
|
||||
("<a>click here</a>;", None, None),
|
||||
("<a>learn more</a>;", None, None),
|
||||
("<a>learn more</a>;", None, None),
|
||||
("<a>learn more.</a>;", None, None),
|
||||
("<a>learn more?</a>;", None, None),
|
||||
("<a>learn more,</a>;", None, None),
|
||||
("<a>learn more!</a>;", None, None),
|
||||
("<a>learn more;</a>;", None, None),
|
||||
("<a>learn more:</a>;", None, None),
|
||||
("<a>link</a>;", None, None),
|
||||
("<a>a link</a>;", None, None),
|
||||
(r#"<a aria-label="click here">something</a>;"#, None, None),
|
||||
("<a> a link </a>;", None, None),
|
||||
("<a>a<i></i> link</a>;", None, None),
|
||||
("<a><i></i>a link</a>;", None, None),
|
||||
("<a><span>click</span> here</a>;", None, None),
|
||||
("<a><span> click </span> here</a>;", None, None),
|
||||
("<a><span aria-hidden>more text</span>learn more</a>;", None, None),
|
||||
(r#"<a><span aria-hidden="true">more text</span>learn more</a>;"#, None, None),
|
||||
(r#"<a><img alt="click here"/></a>;"#, None, None),
|
||||
(r#"<a alt="tutorial on using eslint-plugin-jsx-a11y">click here</a>;"#, None, None),
|
||||
(
|
||||
r#"<a><span alt="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>;"#,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
("<a><CustomElement>click</CustomElement> here</a>;", None, None),
|
||||
(
|
||||
"<Link>here</Link>",
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Link": "a" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
r#"<a><Image alt="click here" /></a>"#,
|
||||
None,
|
||||
Some(
|
||||
serde_json::json!({ "settings": { "jsx-a11y": { "components": { "Image": "img" } } } }),
|
||||
),
|
||||
),
|
||||
(
|
||||
"<a>a disallowed word</a>",
|
||||
Some(serde_json::json!([{ "words": ["a disallowed word"], }])),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
Tester::new(AnchorAmbiguousText::NAME, pass, fail).test_and_snapshot();
|
||||
}
|
||||
170
crates/oxc_linter/src/snapshots/anchor_ambiguous_text.snap
Normal file
170
crates/oxc_linter/src/snapshots/anchor_ambiguous_text.snap
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
---
|
||||
source: crates/oxc_linter/src/tester.rs
|
||||
---
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>here</a>;
|
||||
· ───────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>HERE</a>;
|
||||
· ───────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>click here</a>;
|
||||
· ─────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more</a>;
|
||||
· ─────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more</a>;
|
||||
· ──────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more.</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more?</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more,</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more!</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more;</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>learn more:</a>;
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>link</a>;
|
||||
· ───────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>a link</a>;
|
||||
· ─────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a aria-label="click here">something</a>;
|
||||
· ────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a> a link </a>;
|
||||
· ───────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>a<i></i> link</a>;
|
||||
· ────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><i></i>a link</a>;
|
||||
· ────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><span>click</span> here</a>;
|
||||
· ──────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><span> click </span> here</a>;
|
||||
· ────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><span aria-hidden>more text</span>learn more</a>;
|
||||
· ───────────────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><span aria-hidden="true">more text</span>learn more</a>;
|
||||
· ──────────────────────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><img alt="click here"/></a>;
|
||||
· ──────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a alt="tutorial on using eslint-plugin-jsx-a11y">click here</a>;
|
||||
· ────────────────────────────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><span alt="tutorial on using eslint-plugin-jsx-a11y">click here</span></a>;
|
||||
· ─────────────────────────────────────────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><CustomElement>click</CustomElement> here</a>;
|
||||
· ────────────────────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <Link>here</Link>
|
||||
· ─────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a><Image alt="click here" /></a>
|
||||
· ─────────────────────────────────
|
||||
╰────
|
||||
|
||||
⚠ eslint-plugin-jsx-a11y(anchor-ambiguous-text): Unexpected ambagious anchor link text.
|
||||
╭─[anchor_ambiguous_text.tsx:1:1]
|
||||
1 │ <a>a disallowed word</a>
|
||||
· ────────────────────────
|
||||
╰────
|
||||
Loading…
Reference in a new issue