Skip to content
This repository has been archived by the owner on Aug 31, 2023. It is now read-only.

feat(rome_js_analyze): rule useValidAnchor #3369

Merged
merged 1 commit into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/rome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ define_dategories! {
"lint/nursery/noArrayIndexKey": "https://rome.tools/docs/lint/rules/noArrayIndexKey",
"lint/nursery/noDangerouslySetInnerHtmlWithChildren": "https://rome.tools/docs/lint/rules/noDangerouslySetInnerHtmlWithChildren",
"lint/nursery/noAutofocus": "https://rome.tools/docs/lint/rules/noAutofocus",
"lint/nursery/useValidAnchor": "https://rome.tools/docs/lint/rules/useValidAnchor",
"lint/style/noNegationElse": "https://rome.tools/docs/lint/rules/noNegationElse",
"lint/style/noShoutyConstants": "https://rome.tools/docs/lint/rules/noShoutyConstants",
"lint/style/useSelfClosingElements": "https://rome.tools/docs/lint/rules/useSelfClosingElements",
Expand Down
3 changes: 2 additions & 1 deletion crates/rome_js_analyze/src/analyzers/nursery.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

310 changes: 310 additions & 0 deletions crates/rome_js_analyze/src/analyzers/nursery/use_valid_anchor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
use rome_analyze::context::RuleContext;
use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic};
use rome_console::{markup, MarkupBuf};
use rome_js_syntax::{
JsAnyExpression, JsAnyLiteralExpression, JsAnyTemplateElement, JsxAnyAttributeValue,
JsxAttribute, JsxElement, JsxSelfClosingElement,
};
use rome_rowan::{declare_node_union, AstNode, AstNodeList, TextRange};

declare_rule! {
/// Enforce that all anchors are valid, and they are navigable elements.
///
/// The anchor element (`<a></a>`) - also called **hyperlink** - is an important element
/// that allows users to navigate pages, in the same page, same website or on another website.
///
/// While before it was possible to attach logic to an anchor element, with the advent of JSX libraries,
/// it's now easier to attach logic to any HTML element, anchors included.
///
/// This rule is designed to prevent users to attach logic at the click of anchors, and also makes
/// sure that the `href` provided to the anchor element is valid. If the anchor has logic attached to it,
/// the rules suggests to turn it to a `button`, because that's likely what the user wants.
///
/// Anchor `<a></a>` elements should be used for navigation, while `<button></button>` should be
/// used for user interaction.
///
/// There are **many reasons** why an anchor should not have a logic and have a correct `href` attribute:
/// - it can disrupt the correct flow of the user navigation e.g. a user that wants to open the link
/// in another tab, but the default "click" behaviour is prevented;
/// - it can source of invalid links, and [crawlers] can't navigate the website, risking to penalise
/// [SEO] ranking
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// <a href={null}>navigate here</a>
/// ```
/// ```jsx,expect_diagnostic
/// <a href={undefined}>navigate here</a>
/// ```
/// ```jsx,expect_diagnostic
/// <a href>navigate here</a>
/// ```
/// ```jsx,expect_diagnostic
/// <a href="javascript:void(0)">navigate here</a>
/// ```
/// ```jsx,expect_diagnostic
/// <a href="https://example.com" onClick={something}>navigate here</a>
/// ```
/// ### Valid
///
/// ```jsx
/// <>
/// <a href={`https://www.javascript.com`}>navigate here</a>
/// <a href={somewhere}>navigate here</a>
/// </>
/// ```
///
/// ## Accessibility guidelines
///
/// [WCAG 2.1.1]
///
/// ## Resources
///
/// - [WebAIM - Introduction to Links and Hypertext]
/// - [Links vs. Buttons in Modern Web Applications]
/// - [Using ARIA - Notes on ARIA use in HTML]
///
/// [SEO]: https://en.wikipedia.org/wiki/Search_engine_optimization
/// [crawlers]: https://en.wikipedia.org/wiki/Web_crawler
/// [WCAG 2.1.1]: https://www.w3.org/WAI/WCAG21/Understanding/keyboard
/// [WebAIM - Introduction to Links and Hypertext]: https://webaim.org/techniques/hypertext/
/// [Links vs. Buttons in Modern Web Applications]: https://marcysutton.com/links-vs-buttons-in-modern-web-applications/
/// [Using ARIA - Notes on ARIA use in HTML]: https://www.w3.org/TR/using-aria/#NOTES
pub(crate) UseValidAnchor {
version: "10.0.0",
name: "useValidAnchor",
recommended: false,
}
}

declare_node_union! {
pub(crate) UseValidAnchorQuery = JsxElement | JsxSelfClosingElement
}

/// Representation of the various states
///
/// The `TextRange` of each variant represents the range of where the issue
/// is found.
pub(crate) enum UseValidAnchorState {
/// The anchor element has not `href` attribute
MissingHrefAttribute(TextRange),
/// The `href` attribute has not value
HrefNotInitialized(TextRange),
/// The value assigned to attribute `href` is not valid
IncorrectHref(TextRange),
/// The element has `href` and `onClick`
CantBeAnchor(TextRange),
}

impl UseValidAnchorState {
fn message(&self) -> MarkupBuf {
match self {
UseValidAnchorState::MissingHrefAttribute(_) => {
(markup! {
"Provide a "<Emphasis>"href"</Emphasis>" attribute for the "<Emphasis>"a"</Emphasis>" element."
}).to_owned()
},
UseValidAnchorState::IncorrectHref(_) => {
(markup! {
"Provide a valid value for the attribute "<Emphasis>"href"</Emphasis>"."
}).to_owned()
}
UseValidAnchorState::HrefNotInitialized(_) => {
(markup! {
"The attribute "<Emphasis>"href"</Emphasis>" has to be assigned to a valid value."
}).to_owned()
}
UseValidAnchorState::CantBeAnchor(_) => {
(markup! {
"Use a "<Emphasis>"button"</Emphasis>" element instead of an "<Emphasis>"a"</Emphasis>" element."
}).to_owned()
}
}
}

fn note(&self) -> MarkupBuf {
match self {
UseValidAnchorState::MissingHrefAttribute(_) => (markup! {
"An anchor element should always have a "<Emphasis>"href"</Emphasis>""
})
.to_owned(),
UseValidAnchorState::IncorrectHref(_) | UseValidAnchorState::HrefNotInitialized(_) => {
(markup! {
"The href attribute should be a valid a URL"
})
.to_owned()
}
UseValidAnchorState::CantBeAnchor(_) => (markup! {
"Anchor elements should only be used for default sections or page navigation"
})
.to_owned(),
}
}

fn range(&self) -> &TextRange {
match self {
UseValidAnchorState::MissingHrefAttribute(range)
| UseValidAnchorState::HrefNotInitialized(range)
| UseValidAnchorState::CantBeAnchor(range)
| UseValidAnchorState::IncorrectHref(range) => range,
}
}
}

impl UseValidAnchorQuery {
/// Checks if the current element is anchor
fn is_anchor(&self) -> Option<bool> {
Some(match self {
UseValidAnchorQuery::JsxElement(element) => {
element.opening_element().ok()?.name().ok()?.text() == "a"
}
UseValidAnchorQuery::JsxSelfClosingElement(element) => {
element.name().ok()?.text() == "a"
}
})
}

/// Finds the `href` attribute
fn find_href_attribute(&self) -> Option<JsxAttribute> {
match self {
UseValidAnchorQuery::JsxElement(element) => element
.opening_element()
.ok()?
.find_attribute_by_name("href")
.ok()?,
UseValidAnchorQuery::JsxSelfClosingElement(element) => {
element.find_attribute_by_name("href").ok()?
}
}
}

/// Finds the `onClick` attribute
fn find_on_click_attribute(&self) -> Option<JsxAttribute> {
match self {
UseValidAnchorQuery::JsxElement(element) => element
.opening_element()
.ok()?
.find_attribute_by_name("onClick")
.ok()?,
UseValidAnchorQuery::JsxSelfClosingElement(element) => {
element.find_attribute_by_name("onClick").ok()?
}
}
}
}

impl Rule for UseValidAnchor {
type Query = Ast<UseValidAnchorQuery>;
type State = UseValidAnchorState;
type Signals = Option<Self::State>;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
if !node.is_anchor()? {
return None;
}

let anchor_attribute = node.find_href_attribute();
let on_click_attribute = node.find_on_click_attribute();

match (anchor_attribute, on_click_attribute) {
(Some(_), Some(_)) => Some(UseValidAnchorState::CantBeAnchor(
node.syntax().text_trimmed_range(),
)),
(Some(anchor_attribute), _) => is_invalid_anchor(&anchor_attribute),
(None, Some(on_click_attribute)) => Some(UseValidAnchorState::CantBeAnchor(
on_click_attribute.syntax().text_trimmed_range(),
)),
(None, _) => Some(UseValidAnchorState::MissingHrefAttribute(
node.syntax().text_trimmed_range(),
)),
}
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let diagnostic = RuleDiagnostic::new(rule_category!(), state.range(), state.message())
.footer_note(state.note())
.footer_note(
markup! {
"Check "<Hyperlink href="https://marcysutton.com/links-vs-buttons-in-modern-web-applications">"this thorough explanation"</Hyperlink>" to better understand the context."
}
);

Some(diagnostic)
}
}

fn is_invalid_anchor(anchor_attribute: &JsxAttribute) -> Option<UseValidAnchorState> {
let initializer = anchor_attribute.initializer();
if initializer.is_none() {
return Some(UseValidAnchorState::HrefNotInitialized(
anchor_attribute.syntax().text_range(),
));
}

let attribute_value = initializer?.value().ok()?;

match attribute_value {
JsxAnyAttributeValue::JsxExpressionAttributeValue(attribute_value) => {
let expression = attribute_value.expression().ok()?;
// href={null}
if let JsAnyExpression::JsAnyLiteralExpression(
JsAnyLiteralExpression::JsNullLiteralExpression(null),
) = expression
{
return Some(UseValidAnchorState::IncorrectHref(
null.syntax().text_trimmed_range(),
));
} else if let JsAnyExpression::JsIdentifierExpression(identifier) = expression {
let text = identifier.name().ok()?.value_token().ok()?;
// href={undefined}
if text.text_trimmed() == "undefined" {
return Some(UseValidAnchorState::IncorrectHref(
text.text_trimmed_range(),
));
}
} else if let JsAnyExpression::JsAnyLiteralExpression(
JsAnyLiteralExpression::JsStringLiteralExpression(string_literal),
) = expression
{
let text = string_literal.inner_string_text().ok()?;
if text == "#" {
return Some(UseValidAnchorState::IncorrectHref(
string_literal.syntax().text_trimmed_range(),
));
}
} else if let JsAnyExpression::JsTemplate(template) = expression {
let mut iter = template.elements().iter();
if let Some(JsAnyTemplateElement::JsTemplateChunkElement(element)) = iter.next() {
let template_token = element.template_chunk_token().ok()?;
let text = template_token.text_trimmed();
if text == "#" || text.contains("javascript:") {
return Some(UseValidAnchorState::IncorrectHref(
template_token.text_trimmed_range(),
));
}
}
} else {
return Some(UseValidAnchorState::IncorrectHref(
expression.syntax().text_trimmed_range(),
));
}
}
JsxAnyAttributeValue::JsxAnyTag(_) => {}
JsxAnyAttributeValue::JsxString(href_string) => {
let href_value = href_string.inner_string_text().ok()?;

// href="#" or href="javascript:void(0)"
if href_value == "#" || href_value.contains("javascript:") {
return Some(UseValidAnchorState::IncorrectHref(
href_string.syntax().text_trimmed_range(),
));
}
}
}

None
}
19 changes: 19 additions & 0 deletions crates/rome_js_analyze/tests/specs/nursery/useValidAnchor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<>
{/* invalid */}
<a />
<a href/>
<a href={null}/>
<a href={undefined}/>
<a href="#"/>
<a href={"#"}/>
<a href={`#`}/>
<a href="javascript:void(0)"/>
<a href={"javascript:void(0)"}/>
<a href={`javascript:void(0)`}/>
<a onClick={}/>
<a onClick={} href={}/>
<a href={<span><span className="token string">javascript:void(0)</span></span>}/>
{/* valid */}
<a href={`https://www.javascript.com`}/>
<a href={somewhere}/>
</>
Loading