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

feat(rome_js_analyze): useHeadingContent #4218

Closed
wants to merge 8 commits into from
Closed
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 @@ -105,6 +105,7 @@ define_dategories! {
"lint/nursery/noGlobalObjectCalls": "https://docs.rome.tools/lint/rules/noGlobalObjectCalls",
"lint/nursery/noPrototypeBuiltins": "https://docs.rome.tools/lint/rules/noPrototypeBuiltins",
"lint/nursery/noSelfAssignment": "https://docs.rome.tools/lint/rules/noSelfAssignment",
"lint/nursery/useHeadingContent": "https://docs.rome.tools/lint/rules/useHeadingContent",
// Insert new nursery rule here

// performance
Expand Down
126 changes: 7 additions & 119 deletions crates/rome_js_analyze/src/analyzers/a11y/use_anchor_content.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
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::{JsxElement, JsxSelfClosingElement};
use rome_rowan::{declare_node_union, AstNode};

use crate::aria::{is_accessible_to_screen_reader, is_aria_hidden_truthy};

declare_rule! {
/// Enforce that anchor elements have content and that the content is accessible to screen readers.
Expand Down Expand Up @@ -87,7 +86,7 @@ impl UseAnchorContentNode {
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)
is_aria_hidden_truthy(&aria_hidden_attribute).unwrap_or(false)
}
_ => false,
}
Expand All @@ -98,7 +97,7 @@ impl UseAnchorContentNode {
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)
is_aria_hidden_truthy(&aria_hidden_attribute).unwrap_or(false)
}
_ => false,
}
Expand All @@ -113,7 +112,7 @@ impl UseAnchorContentNode {
UseAnchorContentNode::JsxElement(element) => element
.children()
.into_iter()
.any(|child| is_accessible_to_screen_reader(child).unwrap_or(true)),
.any(|child| is_accessible_to_screen_reader(&child).unwrap_or(true)),
UseAnchorContentNode::JsxSelfClosingElement(element) => element
.find_attribute_by_name("dangerouslySetInnerHTML")
.ok()?
Expand Down Expand Up @@ -158,114 +157,3 @@ 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 aria_hidden_value = aria_hidden_string.inner_string_text().ok()?;
aria_hidden_value == "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 text = string_literal.inner_string_text().ok()?;
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"
}
Some(AnyJsTemplateElement::JsTemplateElement(element)) => {
let expression = element.expression().ok()?;
is_expression_truthy(expression)?
}
_ => false,
}
}
_ => false,
})
}
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.

186 changes: 186 additions & 0 deletions crates/rome_js_analyze/src/analyzers/nursery/use_heading_content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
use rome_analyze::{context::RuleContext, declare_rule, Ast, Rule, RuleDiagnostic};
use rome_console::markup;
use rome_js_syntax::{AnyJsxAttribute, JsxAttribute, JsxElement, JsxSelfClosingElement};
use rome_rowan::{declare_node_union, AstNode};

use crate::aria::{is_accessible_to_screen_reader, is_aria_hidden_truthy};

declare_rule! {
/// Enforce that heading elements (h1, h2, etc.) have content and that the content is accessible to screen readers.
/// Accessible means that it is not hidden using the aria-hidden prop.
///
/// ## Examples
///
/// ### Invalid
///
/// ```jsx,expect_diagnostic
/// <h1 />
/// ```
///
/// ```jsx,expect_diagnostic
/// <h1><div aria-hidden /></h1>
/// ```
///
/// ```jsx,expect_diagnostic
/// <h1></h1>
/// ```
///
/// ## Valid
///
/// ```jsx
/// <h1>heading</h1>
/// ```
///
/// ```jsx
/// <h1><div aria-hidden="true"></div>visible content</h1>
/// ```
///
/// ```jsx
/// <h1 dangerouslySetInnerHTML={{ __html: "heading" }} />
/// ```
///
/// ```jsx
/// <h1><div aria-hidden />visible content</h1>
/// ```
///
pub(crate) UseHeadingContent {
version: "next",
name: "useHeadingContent",
recommended: false,
}
}

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

const HEADING_ELEMENTS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"];

impl Rule for UseHeadingContent {
type Query = Ast<UseHeadingContentNode>;
type State = ();
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();

if node.is_heading_element()? {
if node.has_truthy_aria_hidden_attribute()? {
return Some(());
}

if node.has_valid_children_attribute()? || node.has_spread_prop()? {
return None;
}

if !node.has_dangerously_set_inner_html_attribute() {
match node {
UseHeadingContentNode::JsxElement(element) => {
if !element.children().into_iter().any(|child_node| {
is_accessible_to_screen_reader(&child_node) != Some(false)
}) {
return Some(());
}
}
UseHeadingContentNode::JsxSelfClosingElement(_) => {
return Some(());
}
}
}
}

None
}

fn diagnostic(ctx: &RuleContext<Self>, _: &Self::State) -> Option<RuleDiagnostic> {
let range = ctx.query().syntax().text_trimmed_range();
Some(RuleDiagnostic::new(
rule_category!(),
range,
markup! {
"Provide screen reader accessible content when using "<Emphasis>"heading"</Emphasis>" elements."
},
).note(
"All headings on a page should have content that is accessible to screen readers."
))
}
}

impl UseHeadingContentNode {
fn is_heading_element(&self) -> Option<bool> {
let name_node = match self {
UseHeadingContentNode::JsxElement(element) => {
element.opening_element().ok()?.name().ok()?
}
UseHeadingContentNode::JsxSelfClosingElement(element) => element.name().ok()?,
};
Some(
HEADING_ELEMENTS.contains(&name_node.as_jsx_name()?.value_token().ok()?.text_trimmed()),
)
}

fn find_attribute_by_name(&self, name: &str) -> Option<JsxAttribute> {
match self {
denbezrukov marked this conversation as resolved.
Show resolved Hide resolved
UseHeadingContentNode::JsxElement(element) => {
let opening_element = element.opening_element().ok()?;
opening_element.find_attribute_by_name(name).ok()?
}
UseHeadingContentNode::JsxSelfClosingElement(element) => {
element.find_attribute_by_name(name).ok()?
}
}
}

fn has_dangerously_set_inner_html_attribute(&self) -> bool {
self.find_attribute_by_name("dangerouslySetInnerHTML")
.is_some()
}

fn has_truthy_aria_hidden_attribute(&self) -> Option<bool> {
if let Some(attribute) = self.find_attribute_by_name("aria-hidden") {
Some(!self.has_trailing_spread_prop(&attribute)? && is_aria_hidden_truthy(&attribute)?)
} else {
Some(false)
}
}

fn has_valid_children_attribute(&self) -> Option<bool> {
if let Some(attribute) = self.find_attribute_by_name("children") {
if attribute.initializer().is_some()
&& !(attribute.is_value_undefined_or_null() || attribute.is_value_empty_string())
{
return Some(true);
}
}

Some(false)
}

fn has_trailing_spread_prop(&self, current_attribute: &JsxAttribute) -> Option<bool> {
match self {
UseHeadingContentNode::JsxElement(element) => {
let opening_element = element.opening_element().ok()?;
Some(opening_element.has_trailing_spread_prop(current_attribute.clone()))
}
UseHeadingContentNode::JsxSelfClosingElement(element) => {
Some(element.has_trailing_spread_prop(current_attribute.clone()))
}
}
}

fn has_spread_prop(&self) -> Option<bool> {
let attrs = match self {
UseHeadingContentNode::JsxElement(element) => {
element.opening_element().ok()?.attributes()
}
UseHeadingContentNode::JsxSelfClosingElement(element) => element.attributes(),
};

Some(
attrs
.into_iter()
.any(|attribute| matches!(attribute, AnyJsxAttribute::JsxSpreadAttribute(_))),
)
}
}
Loading