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

feat(rome_js_analyze): implement useHeadingContent and refactor useAnchorContent #4423

Merged
merged 4 commits into from
Apr 30, 2023
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ output. [#4405](https://github.com/rome/tools/pull/4405)
- [`useGroupedTypeImport`](https://docs.rome.tools/lint/rules/useGroupedTypeImport/)
- [`noUselessConstructor`](https://docs.rome.tools/lint/rules/noUselessConstructor/)
- [`useLiteralEnumMembers`](https://docs.rome.tools/lint/rules/useLiteralEnumMembers/)
- [`useHeadingContent`](https://docs.rome.tools/lint/rules/useHeadingContent/)

#### Other changes

Expand Down
8 changes: 4 additions & 4 deletions crates/rome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,10 @@ define_categories! {
"lint/nursery/noConsoleLog": "https://docs.rome.tools/lint/rules/noConsoleLog",
"lint/nursery/noForEach": "https://docs.rome.tools/lint/rules/noForEach",
"lint/nursery/useLiteralKeys": "https://docs.rome.tools/lint/rules/useLiteralKeys",
"lint/nursery/noUselessConstructor": "https://docs.rome.tools/lint/rules/noUselessConstructor",
"lint/nursery/useLiteralEnumMembers": "https://docs.rome.tools/lint/rules/useLiteralEnumMembers",
"lint/nursery/useGroupedTypeImport": "https://docs.rome.tools/lint/rules/useGroupedTypeImport",
// Insert new nursery rule here
"lint/nursery/noUselessConstructor": "https://docs.rome.tools/lint/rules/noUselessConstructor",
"lint/nursery/useLiteralEnumMembers": "https://docs.rome.tools/lint/rules/useLiteralEnumMembers",
"lint/nursery/useGroupedTypeImport": "https://docs.rome.tools/lint/rules/useGroupedTypeImport",
"lint/nursery/useHeadingContent": "https://docs.rome.tools/lint/rules/useHeadingContent",
"lint/nursery/noRedeclare": "https://docs.rome.tools/lint/rules/noRedeclare",
"lint/nursery/useNamespaceKeyword": "https://docs.rome.tools/lint/rules/useNamespaceKeyword",
"lint/nursery/noRedundantRoles": "https://docs.rome.tools/lint/rules/noRedundantRoles",
Expand Down
234 changes: 50 additions & 184 deletions crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
use rome_analyze::context::RuleContext;
use rome_analyze::{declare_rule, Ast, Rule, RuleDiagnostic};
use rome_console::markup;
use rome_js_syntax::{
AnyJsExpression, AnyJsLiteralExpression, AnyJsTemplateElement, AnyJsxAttributeValue,
AnyJsxChild, JsxAttribute, JsxElement, JsxReferenceIdentifier, JsxSelfClosingElement,
};
use rome_rowan::{declare_node_union, AstNode, AstNodeList};
use rome_js_syntax::jsx_ext::AnyJsxElement;
use rome_js_syntax::JsxElement;
use rome_rowan::AstNode;

declare_rule! {
/// Enforce that anchors have content and that the content is accessible to screen readers.
///
/// Accessible means that it is not hidden using the aria-hidden prop. Refer to the references to learn about why this is important.
/// Accessible means the content is not hidden using the `aria-hidden` attribute.
/// Refer to the references to learn about why this is important.
///
/// ## Examples
///
Expand Down Expand Up @@ -69,91 +68,52 @@ declare_rule! {
}
}

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

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

/// Check if the `a` element has the `aria-hidden` attribute set to true.
fn is_hidden_from_screen_reader(&self) -> bool {
match self {
UseAnchorContentNode::JsxElement(element) => {
if let Ok(opening_element) = element.opening_element() {
match opening_element.find_attribute_by_name("aria-hidden") {
Ok(Some(aria_hidden_attribute)) => {
is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false)
}
_ => false,
}
} else {
false
}
}
UseAnchorContentNode::JsxSelfClosingElement(element) => {
match element.find_attribute_by_name("aria-hidden") {
Ok(Some(aria_hidden_attribute)) => {
is_aria_hidden_truthy(aria_hidden_attribute).unwrap_or(false)
}
_ => false,
}
}
}
}

/// Check if the `a` element has content accessible to screen readers.
/// Accessible means that the content is not hidden using the `aria-hidden` attribute.
fn has_accessible_child(&self) -> Option<bool> {
Some(match self {
UseAnchorContentNode::JsxElement(element) => element
.children()
.into_iter()
.any(|child| is_accessible_to_screen_reader(child).unwrap_or(true)),
UseAnchorContentNode::JsxSelfClosingElement(element) => element
.find_attribute_by_name("dangerouslySetInnerHTML")
.ok()?
.is_some(),
})
}
}

impl Rule for UseAnchorContent {
type Query = Ast<UseAnchorContentNode>;
type Query = Ast<AnyJsxElement>;
type State = ();
type Signals = Option<Self::State>;
type Options = ();

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

if name.text_trimmed() == "a" {
if node.has_truthy_attribute("aria-hidden") {
return Some(());
}

// If there's no `aria-hidden` attribute on the `a` element,
// proceed to check the accessibility of its child elements
if !node.is_hidden_from_screen_reader() && node.has_accessible_child()? {
return None;
if has_valid_anchor_content(node) {
return None;
}

match node {
AnyJsxElement::JsxOpeningElement(_) => {
let children = node.parent::<JsxElement>()?.children();
if !children
.into_iter()
.any(|child| child.is_accessible_node().unwrap_or(true))
{
return Some(());
}
}
AnyJsxElement::JsxSelfClosingElement(_) => return Some(()),
}
}

Some(())
None
}

fn diagnostic(ctx: &RuleContext<Self>, _state: &Self::State) -> Option<RuleDiagnostic> {
let node = ctx.query();
let range = match ctx.query() {
AnyJsxElement::JsxOpeningElement(node) => {
node.parent::<JsxElement>()?.syntax().text_range()
}
AnyJsxElement::JsxSelfClosingElement(node) => node.syntax().text_trimmed_range(),
};
Some(RuleDiagnostic::new(
rule_category!(),
node.syntax().text_trimmed_range(),
range,
markup! {
"Provide screen reader accessible content when using "<Emphasis>"`a`"</Emphasis>" elements."
}
Expand All @@ -165,113 +125,19 @@ impl Rule for UseAnchorContent {
}
}

/// Check if the element is a text content for screen readers,
/// or it is not hidden using the `aria-hidden` attribute
fn is_accessible_to_screen_reader(element: AnyJsxChild) -> Option<bool> {
Some(match element {
AnyJsxChild::JsxText(text) => {
let value_token = text.value_token().ok()?;
value_token.text_trimmed().trim() != ""
}
AnyJsxChild::JsxElement(element) => {
let opening_element = element.opening_element().ok()?;

// We don't check if a component (e.g. <Text aria-hidden />) is using the `aria-hidden` property,
// since we don't have enough information about how the property is used.
let element_name = opening_element.name().ok()?;
if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) {
return None;
}

let aria_hidden_attribute = opening_element
.find_attribute_by_name("aria-hidden")
.ok()??;
!is_aria_hidden_truthy(aria_hidden_attribute)?
}
AnyJsxChild::JsxSelfClosingElement(element) => {
// We don't check if a component (e.g. <Text aria-hidden />) is using the `aria-hidden` property,
// since we don't have enough information about how the property is used.
let element_name = element.name().ok()?;
if JsxReferenceIdentifier::can_cast(element_name.syntax().kind()) {
return None;
}

let aria_hidden_attribute = element.find_attribute_by_name("aria-hidden").ok()??;
!is_aria_hidden_truthy(aria_hidden_attribute)?
}
AnyJsxChild::JsxExpressionChild(expression) => {
let expression = expression.expression()?;
match expression {
AnyJsExpression::AnyJsLiteralExpression(
AnyJsLiteralExpression::JsNullLiteralExpression(_),
) => false,
AnyJsExpression::JsIdentifierExpression(identifier) => {
let text = identifier.name().ok()?.value_token().ok()?;
return Some(text.text_trimmed() != "undefined");
}
_ => true,
}
}
_ => true,
})
}

/// Check if the `aria-hidden` attribute is present or the value is true.
fn is_aria_hidden_truthy(aria_hidden_attribute: JsxAttribute) -> Option<bool> {
let initializer = aria_hidden_attribute.initializer();
if initializer.is_none() {
return Some(true);
}
let attribute_value = initializer?.value().ok()?;
Some(match attribute_value {
AnyJsxAttributeValue::JsxExpressionAttributeValue(attribute_value) => {
let expression = attribute_value.expression().ok()?;
is_expression_truthy(expression)?
}
AnyJsxAttributeValue::AnyJsxTag(_) => false,
AnyJsxAttributeValue::JsxString(aria_hidden_string) => {
let quoted_string = aria_hidden_string.inner_string_text().ok()?;
quoted_string.text() == "true"
}
})
}

/// Check if the expression contains only one boolean literal `true`
/// or one string literal `"true"`
fn is_expression_truthy(expression: AnyJsExpression) -> Option<bool> {
Some(match expression {
AnyJsExpression::AnyJsLiteralExpression(literal_expression) => {
if let AnyJsLiteralExpression::JsBooleanLiteralExpression(boolean_literal) =
literal_expression
{
let text = boolean_literal.value_token().ok()?;
text.text_trimmed() == "true"
} else if let AnyJsLiteralExpression::JsStringLiteralExpression(string_literal) =
literal_expression
{
let quoted_string = string_literal.inner_string_text().ok()?;
quoted_string.text() == "true"
} else {
false
}
}
AnyJsExpression::JsTemplateExpression(template) => {
let mut iter = template.elements().iter();
if iter.len() != 1 {
return None;
}
match iter.next() {
Some(AnyJsTemplateElement::JsTemplateChunkElement(element)) => {
let template_token = element.template_chunk_token().ok()?;
template_token.text_trimmed() == "true"
/// check if the node has a valid anchor attribute
fn has_valid_anchor_content(node: &AnyJsxElement) -> bool {
node.find_attribute_by_name("dangerouslySetInnerHTML")
.is_some()
|| node
.find_attribute_by_name("children")
.map_or(false, |attribute| {
if attribute.initializer().is_none() {
return false;
}
Some(AnyJsTemplateElement::JsTemplateElement(element)) => {
let expression = element.expression().ok()?;
is_expression_truthy(expression)?
}
_ => false,
}
}
_ => false,
})
attribute
.as_static_value()
.map_or(true, |attribute| !attribute.is_falsy())
})
|| node.has_spread_prop()
}
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.

Loading